Răsfoiți Sursa

Merge pull request #1094 from digitalocean/develop

Release v1.9.6
Jeremy Stretch 9 ani în urmă
părinte
comite
17873706b7

+ 13 - 2
docs/installation/netbox.md

@@ -6,6 +6,7 @@ Python 3:
 
 
 ```no-highlight
 ```no-highlight
 # apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
 # apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
+# update-alternatives --install /usr/bin/python python /usr/bin/python3 1
 ```
 ```
 
 
 Python 2:
 Python 2:
@@ -20,7 +21,9 @@ Python 3:
 
 
 ```no-highlight
 ```no-highlight
 # yum install -y epel-release
 # yum install -y epel-release
-# yum install -y gcc python3 python3-devel python3-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
+# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
+# easy_install-3.4 pip
+# ln -s -f python3.4 /usr/bin/python
 ```
 ```
 
 
 Python 2:
 Python 2:
@@ -83,6 +86,14 @@ Checking connectivity... done.
 
 
 Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
 Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
 
 
+Python 3:
+
+```no-highlight
+# pip3 install -r requirements.txt
+```
+
+Python 2:
+
 ```no-highlight
 ```no-highlight
 # pip install -r requirements.txt
 # pip install -r requirements.txt
 ```
 ```
@@ -172,7 +183,7 @@ Superuser created successfully.
 # Collect Static Files
 # Collect Static Files
 
 
 ```no-highlight
 ```no-highlight
-# ./manage.py collectstatic
+# ./manage.py collectstatic --no-input
 
 
 You have requested to collect static files at the destination
 You have requested to collect static files at the destination
 location as specified in your settings:
 location as specified in your settings:

+ 3 - 2
docs/installation/postgresql.md

@@ -5,13 +5,14 @@ NetBox requires a PostgreSQL database to store data. (Please note that MySQL is
 **Debian/Ubuntu**
 **Debian/Ubuntu**
 
 
 ```no-highlight
 ```no-highlight
-# apt-get install -y postgresql libpq-dev python-psycopg2
+# apt-get update
+# apt-get install -y postgresql libpq-dev
 ```
 ```
 
 
 **CentOS/RHEL**
 **CentOS/RHEL**
 
 
 ```no-highlight
 ```no-highlight
-# yum install -y postgresql postgresql-server postgresql-devel python-psycopg2
+# yum install -y postgresql postgresql-server postgresql-devel
 # postgresql-setup initdb
 # postgresql-setup initdb
 ```
 ```
 
 

+ 21 - 0
netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-04-19 17:17
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0007_circuit_add_description'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='interface',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_termination', to='dcim.Interface'),
+        ),
+    ]

+ 7 - 3
netbox/circuits/models.py

@@ -150,10 +150,14 @@ class CircuitTermination(models.Model):
     circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
     circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
     term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
     term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
     site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
     site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
-    interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True)
+    interface = models.OneToOneField(
+        'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT
+    )
     port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
     port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
-    upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
-                                                 help_text='Upstream speed, if different from port speed')
+    upstream_speed = models.PositiveIntegerField(
+        blank=True, null=True, verbose_name='Upstream speed (Kbps)',
+        help_text='Upstream speed, if different from port speed'
+    )
     xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
     xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
     pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
     pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
 
 

+ 2 - 4
netbox/circuits/views.py

@@ -95,7 +95,7 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
     model = CircuitType
     model = CircuitType
     form_class = forms.CircuitTypeForm
     form_class = forms.CircuitTypeForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('circuits:circuittype_list')
         return reverse('circuits:circuittype_list')
 
 
 
 
@@ -142,7 +142,6 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'circuits.change_circuit'
     permission_required = 'circuits.change_circuit'
     model = Circuit
     model = Circuit
     form_class = forms.CircuitForm
     form_class = forms.CircuitForm
-    fields_initial = ['provider']
     template_name = 'circuits/circuit_edit.html'
     template_name = 'circuits/circuit_edit.html'
     default_return_url = 'circuits:circuit_list'
     default_return_url = 'circuits:circuit_list'
 
 
@@ -230,7 +229,6 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'circuits.change_circuittermination'
     permission_required = 'circuits.change_circuittermination'
     model = CircuitTermination
     model = CircuitTermination
     form_class = forms.CircuitTerminationForm
     form_class = forms.CircuitTerminationForm
-    fields_initial = ['term_side']
     template_name = 'circuits/circuittermination_edit.html'
     template_name = 'circuits/circuittermination_edit.html'
 
 
     def alter_obj(self, obj, request, url_args, url_kwargs):
     def alter_obj(self, obj, request, url_args, url_kwargs):
@@ -238,7 +236,7 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
             obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
             obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
         return obj
         return obj
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.circuit.get_absolute_url()
         return obj.circuit.get_absolute_url()
 
 
 
 

+ 9 - 32
netbox/dcim/forms.py

@@ -1422,9 +1422,16 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
 
 
         # Limit LAG choices to interfaces which belong to the parent device.
         # Limit LAG choices to interfaces which belong to the parent device.
+        device = None
         if self.initial.get('device'):
         if self.initial.get('device'):
-            self.fields['lag'].queryset = Interface.objects.filter(
-                device=self.initial['device'], form_factor=IFACE_FF_LAG
+            try:
+                device = Device.objects.get(pk=self.initial.get('device'))
+            except Device.DoesNotExist:
+                pass
+        if device is not None:
+            interface_ordering = device.device_type.interface_ordering
+            self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
+                device=device, form_factor=IFACE_FF_LAG
             )
             )
         else:
         else:
             self.fields['lag'].choices = []
             self.fields['lag'].choices = []
@@ -1684,36 +1691,6 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
     device = forms.CharField(required=False, label='Device name')
     device = forms.CharField(required=False, label='Device name')
 
 
 
 
-#
-# IP addresses
-#
-
-class IPAddressForm(BootstrapMixin, CustomFieldForm):
-    set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
-
-    class Meta:
-        model = IPAddress
-        fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description']
-
-    def __init__(self, device, *args, **kwargs):
-
-        super(IPAddressForm, self).__init__(*args, **kwargs)
-
-        self.fields['vrf'].empty_label = 'Global'
-
-        interfaces = device.interfaces.all()
-        self.fields['interface'].queryset = interfaces
-        self.fields['interface'].required = True
-
-        # If this device has only one interface, select it by default.
-        if len(interfaces) == 1:
-            self.fields['interface'].initial = interfaces[0]
-
-        # If this device does not have any IP addresses assigned, default to setting the first IP as its primary.
-        if not IPAddress.objects.filter(interface__device=device).count():
-            self.fields['set_as_primary'].initial = True
-
-
 #
 #
 # Modules
 # Modules
 #
 #

+ 0 - 1
netbox/dcim/urls.py

@@ -116,7 +116,6 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
     url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
     url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
     url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
-    url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
     url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
 
 

+ 13 - 60
netbox/dcim/views.py

@@ -13,7 +13,7 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.http import urlencode
 from django.utils.http import urlencode
 from django.views.generic import View
 from django.views.generic import View
 
 
-from ipam.models import Prefix, IPAddress, Service, VLAN
+from ipam.models import Prefix, Service, VLAN
 from circuits.models import Circuit
 from circuits.models import Circuit
 from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
@@ -124,13 +124,13 @@ class ComponentCreateView(View):
 
 
 class ComponentEditView(ObjectEditView):
 class ComponentEditView(ObjectEditView):
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.device.get_absolute_url()
         return obj.device.get_absolute_url()
 
 
 
 
 class ComponentDeleteView(ObjectDeleteView):
 class ComponentDeleteView(ObjectDeleteView):
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.device.get_absolute_url()
         return obj.device.get_absolute_url()
 
 
 
 
@@ -149,7 +149,7 @@ class RegionEditView(PermissionRequiredMixin, ObjectEditView):
     model = Region
     model = Region
     form_class = forms.RegionForm
     form_class = forms.RegionForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:region_list')
         return reverse('dcim:region_list')
 
 
 
 
@@ -242,7 +242,7 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
     model = RackGroup
     model = RackGroup
     form_class = forms.RackGroupForm
     form_class = forms.RackGroupForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:rackgroup_list')
         return reverse('dcim:rackgroup_list')
 
 
 
 
@@ -268,7 +268,7 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
     model = RackRole
     model = RackRole
     form_class = forms.RackRoleForm
     form_class = forms.RackRoleForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:rackrole_list')
         return reverse('dcim:rackrole_list')
 
 
 
 
@@ -379,7 +379,7 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
             obj.user = request.user
             obj.user = request.user
         return obj
         return obj
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.rack.get_absolute_url()
         return obj.rack.get_absolute_url()
 
 
 
 
@@ -387,7 +387,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_rackreservation'
     permission_required = 'dcim.delete_rackreservation'
     model = RackReservation
     model = RackReservation
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.rack.get_absolute_url()
         return obj.rack.get_absolute_url()
 
 
 
 
@@ -412,7 +412,7 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
     model = Manufacturer
     model = Manufacturer
     form_class = forms.ManufacturerForm
     form_class = forms.ManufacturerForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:manufacturer_list')
         return reverse('dcim:manufacturer_list')
 
 
 
 
@@ -632,7 +632,7 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
     model = DeviceRole
     model = DeviceRole
     form_class = forms.DeviceRoleForm
     form_class = forms.DeviceRoleForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:devicerole_list')
         return reverse('dcim:devicerole_list')
 
 
 
 
@@ -657,7 +657,7 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
     model = Platform
     model = Platform
     form_class = forms.PlatformForm
     form_class = forms.PlatformForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:platform_list')
         return reverse('dcim:platform_list')
 
 
 
 
@@ -700,19 +700,15 @@ def device(request, pk):
     interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
     interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
         .filter(device=device, mgmt_only=False)\
         .filter(device=device, mgmt_only=False)\
         .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
         .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
-                        'circuit_termination__circuit')
+                        'circuit_termination__circuit').prefetch_related('ip_addresses')
     mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
     mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
         .filter(device=device, mgmt_only=True)\
         .filter(device=device, mgmt_only=True)\
         .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
         .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
-                        'circuit_termination__circuit')
+                        'circuit_termination__circuit').prefetch_related('ip_addresses')
     device_bays = natsorted(
     device_bays = natsorted(
         DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
         DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
         key=attrgetter('name')
         key=attrgetter('name')
     )
     )
-
-    # Gather relevant device objects
-    ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\
-        .order_by('address')
     services = Service.objects.filter(device=device)
     services = Service.objects.filter(device=device)
     secrets = device.secrets.all()
     secrets = device.secrets.all()
 
 
@@ -743,7 +739,6 @@ def device(request, pk):
         'interfaces': interfaces,
         'interfaces': interfaces,
         'mgmt_interfaces': mgmt_interfaces,
         'mgmt_interfaces': mgmt_interfaces,
         'device_bays': device_bays,
         'device_bays': device_bays,
-        'ip_addresses': ip_addresses,
         'services': services,
         'services': services,
         'secrets': secrets,
         'secrets': secrets,
         'related_devices': related_devices,
         'related_devices': related_devices,
@@ -755,7 +750,6 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_device'
     permission_required = 'dcim.change_device'
     model = Device
     model = Device
     form_class = forms.DeviceForm
     form_class = forms.DeviceForm
-    fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
     template_name = 'dcim/device_edit.html'
     template_name = 'dcim/device_edit.html'
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
@@ -1567,47 +1561,6 @@ class InterfaceConnectionsListView(ObjectListView):
     template_name = 'dcim/interface_connections_list.html'
     template_name = 'dcim/interface_connections_list.html'
 
 
 
 
-#
-# IP addresses
-#
-
-@permission_required(['dcim.change_device', 'ipam.add_ipaddress'])
-def ipaddress_assign(request, pk):
-
-    device = get_object_or_404(Device, pk=pk)
-
-    if request.method == 'POST':
-        form = forms.IPAddressForm(device, request.POST)
-        if form.is_valid():
-
-            ipaddress = form.save(commit=False)
-            ipaddress.interface = form.cleaned_data['interface']
-            ipaddress.save()
-            form.save_custom_fields()
-            messages.success(request, u"Added new IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
-
-            if form.cleaned_data['set_as_primary']:
-                if ipaddress.family == 4:
-                    device.primary_ip4 = ipaddress
-                elif ipaddress.family == 6:
-                    device.primary_ip6 = ipaddress
-                device.save()
-
-            if '_addanother' in request.POST:
-                return redirect('dcim:ipaddress_assign', pk=device.pk)
-            else:
-                return redirect('dcim:device', pk=device.pk)
-
-    else:
-        form = forms.IPAddressForm(device)
-
-    return render(request, 'dcim/ipaddress_assign.html', {
-        'device': device,
-        'form': form,
-        'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
-    })
-
-
 #
 #
 # Modules
 # Modules
 #
 #

+ 2 - 3
netbox/generate_secret_key.py

@@ -1,8 +1,7 @@
 #!/usr/bin/env python
 #!/usr/bin/env python
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
-import os
 import random
 import random
 
 
 charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
 charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
-random.seed = (os.urandom(2048))
-print(''.join(random.choice(charset) for c in range(50)))
+secure_random = random.SystemRandom()
+print(''.join(secure_random.sample(charset, 50)))

+ 92 - 33
netbox/ipam/forms.py

@@ -6,7 +6,7 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
     APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
-    SlugField, add_blank_choice,
+    ReturnURLForm, SlugField, add_blank_choice,
 )
 )
 
 
 from .models import (
 from .models import (
@@ -210,28 +210,33 @@ class PrefixFromCSVForm(forms.ModelForm):
         site = self.cleaned_data.get('site')
         site = self.cleaned_data.get('site')
         vlan_group_name = self.cleaned_data.get('vlan_group_name')
         vlan_group_name = self.cleaned_data.get('vlan_group_name')
         vlan_vid = self.cleaned_data.get('vlan_vid')
         vlan_vid = self.cleaned_data.get('vlan_vid')
-
-        # Validate VLAN
         vlan_group = None
         vlan_group = None
+        vlan = None
+
+        # Validate VLAN group
         if vlan_group_name:
         if vlan_group_name:
             try:
             try:
                 vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
                 vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
             except VLANGroup.DoesNotExist:
             except VLANGroup.DoesNotExist:
-                self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
-        if vlan_vid and vlan_group:
-            try:
-                self.instance.vlan = VLAN.objects.get(group=vlan_group, vid=vlan_vid)
-            except VLAN.DoesNotExist:
-                self.add_error('vlan_vid', "Invalid VLAN ID ({} - {}).".format(vlan_group, vlan_vid))
-        elif vlan_vid and site:
+                if site:
+                    self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
+                else:
+                    self.add_error('vlan_group_name', "Invalid global VLAN group ({}).".format(vlan_group_name))
+
+        # Validate VLAN
+        if vlan_vid:
             try:
             try:
-                self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
+                self.instance.vlan = VLAN.objects.get(site=site, group=vlan_group, vid=vlan_vid)
             except VLAN.DoesNotExist:
             except VLAN.DoesNotExist:
-                self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
+                if site:
+                    self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
+                elif vlan_group:
+                    self.add_error('vlan_vid', "Invalid VLAN ID ({}) for group {}.".format(vlan_vid, vlan_group_name))
+                elif not vlan_group_name:
+                    self.add_error('vlan_vid', "Invalid global VLAN ID ({}).".format(vlan_vid))
             except VLAN.MultipleObjectsReturned:
             except VLAN.MultipleObjectsReturned:
                 self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
                 self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
-        elif vlan_vid:
-            self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
+            self.instance.vlan = vlan
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
@@ -302,21 +307,46 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # IP addresses
 # IP addresses
 #
 #
 
 
-class IPAddressForm(BootstrapMixin, CustomFieldForm):
-    nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
-                                      widget=forms.Select(attrs={'filter-for': 'nat_device'}))
-    nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
-                                        widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
-                                                         display_field='display_name',
-                                                         attrs={'filter-for': 'nat_inside'}))
-    livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
-        query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
+class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
+    interface_site = forms.ModelChoiceField(
+        queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
+            attrs={'filter-for': 'interface_rack'}
+        )
+    )
+    interface_rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(), required=False, label='Rack', widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{interface_site}}', display_field='display_name',
+            attrs={'filter-for': 'interface_device'}
+        )
+    )
+    interface_device = forms.ModelChoiceField(
+        queryset=Device.objects.all(), 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'}
+        )
+    )
+    nat_site = forms.ModelChoiceField(
+        queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
+            attrs={'filter-for': 'nat_device'}
+        )
+    )
+    nat_device = forms.ModelChoiceField(
+        queryset=Device.objects.all(), required=False, label='Device', widget=APISelect(
+            api_url='/api/dcim/devices/?site_id={{nat_site}}', display_field='display_name',
+            attrs={'filter-for': 'nat_inside'}
+        )
+    )
+    livesearch = forms.CharField(
+        required=False, label='IP Address', widget=Livesearch(
+            query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address'
+        )
     )
     )
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
-        fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
+        fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'nat_inside', 'description']
         widgets = {
         widgets = {
+            'interface': APISelect(api_url='/api/dcim/devices/{{interface_device}}/interfaces/'),
             'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
             'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
         }
         }
 
 
@@ -325,8 +355,37 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
 
 
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
 
 
-        if self.instance.nat_inside:
+        # If an interface has been assigned, initialize site, rack, and device
+        if self.instance.interface:
+            self.initial['interface_site'] = self.instance.interface.device.site
+            self.initial['interface_rack'] = self.instance.interface.device.rack
+            self.initial['interface_device'] = self.instance.interface.device
+
+        # Limit rack choices
+        if self.is_bound and self.data.get('interface_site'):
+            self.fields['interface_rack'].queryset = Rack.objects.filter(site__pk=self.data['interface_site'])
+        elif self.initial.get('interface_site'):
+            self.fields['interface_rack'].queryset = Rack.objects.filter(site=self.initial['interface_site'])
+        else:
+            self.fields['interface_rack'].choices = []
+
+        # Limit device choices
+        if self.is_bound and self.data.get('interface_rack'):
+            self.fields['interface_device'].queryset = Device.objects.filter(rack=self.data['interface_rack'])
+        elif self.initial.get('interface_rack'):
+            self.fields['interface_device'].queryset = Device.objects.filter(rack=self.initial['interface_rack'])
+        else:
+            self.fields['interface_device'].choices = []
 
 
+        # Limit interface choices
+        if self.is_bound and self.data.get('interface_device'):
+            self.fields['interface'].queryset = Interface.objects.filter(device=self.data['interface_device'])
+        elif self.initial.get('interface_device'):
+            self.fields['interface'].queryset = Interface.objects.filter(device=self.initial['interface_device'])
+        else:
+            self.fields['interface'].choices = []
+
+        if self.instance.nat_inside:
             nat_inside = self.instance.nat_inside
             nat_inside = self.instance.nat_inside
             # If the IP is assigned to an interface, populate site/device fields accordingly
             # If the IP is assigned to an interface, populate site/device fields accordingly
             if self.instance.nat_inside.interface:
             if self.instance.nat_inside.interface:
@@ -340,9 +399,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
                 )
                 )
             else:
             else:
                 self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
                 self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
-
         else:
         else:
-
             # Initialize nat_device choices if nat_site is set
             # Initialize nat_device choices if nat_site is set
             if self.is_bound and self.data.get('nat_site'):
             if self.is_bound and self.data.get('nat_site'):
                 self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site'])
                 self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site'])
@@ -350,7 +407,6 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
                 self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site'])
                 self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site'])
             else:
             else:
                 self.fields['nat_device'].choices = []
                 self.fields['nat_device'].choices = []
-
             # Initialize nat_inside choices if nat_device is set
             # Initialize nat_inside choices if nat_device is set
             if self.is_bound and self.data.get('nat_device'):
             if self.is_bound and self.data.get('nat_device'):
                 self.fields['nat_inside'].queryset = IPAddress.objects.filter(
                 self.fields['nat_inside'].queryset = IPAddress.objects.filter(
@@ -362,12 +418,15 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
                 self.fields['nat_inside'].choices = []
                 self.fields['nat_inside'].choices = []
 
 
 
 
-class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
-    address = ExpandableIPAddressField()
+class IPAddressBulkAddForm(BootstrapMixin, CustomFieldForm):
+    address_pattern = ExpandableIPAddressField(label='Address Pattern')
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
     vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
-    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
-    status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
-    description = forms.CharField(max_length=100, required=False)
+
+    pattern_map = ('address_pattern', 'address')
+
+    class Meta:
+        model = IPAddress
+        fields = ['address_pattern', 'vrf', 'tenant', 'status', 'description']
 
 
 
 
 class IPAddressAssignForm(BootstrapMixin, forms.Form):
 class IPAddressAssignForm(BootstrapMixin, forms.Form):

+ 0 - 2
netbox/ipam/urls.py

@@ -57,8 +57,6 @@ urlpatterns = [
     url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
     url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
     url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
     url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
     url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
     url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
-    url(r'^ip-addresses/(?P<pk>\d+)/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
-    url(r'^ip-addresses/(?P<pk>\d+)/remove/$', views.ipaddress_remove, name='ipaddress_remove'),
     url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
     url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
 
 
     # VLAN groups
     # VLAN groups

+ 5 - 76
netbox/ipam/views.py

@@ -244,7 +244,7 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
     model = RIR
     model = RIR
     form_class = forms.RIRForm
     form_class = forms.RIRForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('ipam:rir_list')
         return reverse('ipam:rir_list')
 
 
 
 
@@ -370,7 +370,7 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
     model = Role
     model = Role
     form_class = forms.RoleForm
     form_class = forms.RoleForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('ipam:role_list')
         return reverse('ipam:role_list')
 
 
 
 
@@ -464,7 +464,6 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
     model = Prefix
     model = Prefix
     form_class = forms.PrefixForm
     form_class = forms.PrefixForm
     template_name = 'ipam/prefix_edit.html'
     template_name = 'ipam/prefix_edit.html'
-    fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
     default_return_url = 'ipam:prefix_list'
     default_return_url = 'ipam:prefix_list'
 
 
 
 
@@ -572,80 +571,10 @@ def ipaddress(request, pk):
     })
     })
 
 
 
 
-@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
-def ipaddress_assign(request, pk):
-
-    ipaddress = get_object_or_404(IPAddress, pk=pk)
-
-    if request.method == 'POST':
-        form = forms.IPAddressAssignForm(request.POST)
-        if form.is_valid():
-
-            interface = form.cleaned_data['interface']
-            ipaddress.interface = interface
-            ipaddress.save()
-            messages.success(request, u"Assigned IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
-
-            if form.cleaned_data['set_as_primary']:
-                device = interface.device
-                if ipaddress.family == 4:
-                    device.primary_ip4 = ipaddress
-                elif ipaddress.family == 6:
-                    device.primary_ip6 = ipaddress
-                device.save()
-
-            return redirect('ipam:ipaddress', pk=ipaddress.pk)
-        else:
-            assert False, form.errors
-
-    else:
-        form = forms.IPAddressAssignForm()
-
-    return render(request, 'ipam/ipaddress_assign.html', {
-        'ipaddress': ipaddress,
-        'form': form,
-        'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
-    })
-
-
-@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
-def ipaddress_remove(request, pk):
-
-    ipaddress = get_object_or_404(IPAddress, pk=pk)
-
-    if request.method == 'POST':
-        form = ConfirmationForm(request.POST)
-        if form.is_valid():
-
-            device = ipaddress.interface.device
-            ipaddress.interface = None
-            ipaddress.save()
-            messages.success(request, u"Removed IP address {} from {}.".format(ipaddress, device))
-
-            if device.primary_ip4 == ipaddress.pk:
-                device.primary_ip4 = None
-                device.save()
-            elif device.primary_ip6 == ipaddress.pk:
-                device.primary_ip6 = None
-                device.save()
-
-            return redirect('ipam:ipaddress', pk=ipaddress.pk)
-
-    else:
-        form = ConfirmationForm()
-
-    return render(request, 'ipam/ipaddress_unassign.html', {
-        'ipaddress': ipaddress,
-        'form': form,
-        'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
-    })
-
-
 class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
 class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'ipam.change_ipaddress'
     permission_required = 'ipam.change_ipaddress'
     model = IPAddress
     model = IPAddress
     form_class = forms.IPAddressForm
     form_class = forms.IPAddressForm
-    fields_initial = ['address', 'vrf']
     template_name = 'ipam/ipaddress_edit.html'
     template_name = 'ipam/ipaddress_edit.html'
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'
 
 
@@ -659,7 +588,7 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
 class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
     permission_required = 'ipam.add_ipaddress'
     permission_required = 'ipam.add_ipaddress'
     form = forms.IPAddressBulkAddForm
     form = forms.IPAddressBulkAddForm
-    model = IPAddress
+    model_form = forms.IPAddressForm
     template_name = 'ipam/ipaddress_bulk_add.html'
     template_name = 'ipam/ipaddress_bulk_add.html'
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'
 
 
@@ -718,7 +647,7 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
     model = VLANGroup
     model = VLANGroup
     form_class = forms.VLANGroupForm
     form_class = forms.VLANGroupForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('ipam:vlangroup_list')
         return reverse('ipam:vlangroup_list')
 
 
 
 
@@ -807,7 +736,7 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
             obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
             obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
         return obj
         return obj
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.device.get_absolute_url()
         return obj.device.get_absolute_url()
 
 
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.9.5'
+VERSION = '1.9.6'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:

+ 10 - 0
netbox/project-static/css/base.css

@@ -313,6 +313,16 @@ li.occupied + li.available {
     border-top: 1px solid #474747;
     border-top: 1px solid #474747;
 }
 }
 
 
+/* Devices */
+table.component-list tr.ipaddress td {
+    background-color: #eeffff;
+    padding-bottom: 4px;
+    padding-top: 4px;
+}
+table.component-list tr.ipaddress:hover td {
+    background-color: #e6f7f7;
+}
+
 /* Misc */
 /* Misc */
 .banner-bottom {
 .banner-bottom {
     margin-bottom: 50px;
     margin-bottom: 50px;

+ 1 - 1
netbox/secrets/views.py

@@ -30,7 +30,7 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
     model = SecretRole
     model = SecretRole
     form_class = forms.SecretRoleForm
     form_class = forms.SecretRoleForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('secrets:secretrole_list')
         return reverse('secrets:secretrole_list')
 
 
 
 

+ 21 - 21
netbox/templates/_base.html

@@ -3,11 +3,11 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
 <html lang="en">
 <html lang="en">
 <head>
 <head>
-	<title>NetBox - {% block title %}Home{% endblock %}</title>
-	<link rel="stylesheet" href="{% static 'bootstrap-3.3.6-dist/css/bootstrap.min.css' %}">
+    <title>NetBox - {% block title %}Home{% endblock %}</title>
+    <link rel="stylesheet" href="{% static 'bootstrap-3.3.6-dist/css/bootstrap.min.css' %}">
     <link rel="stylesheet" href="{% static 'font-awesome-4.6.3/css/font-awesome.min.css' %}">
     <link rel="stylesheet" href="{% static 'font-awesome-4.6.3/css/font-awesome.min.css' %}">
     <link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
     <link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
-	<link rel="stylesheet" href="{% static 'css/base.css' %}">
+    <link rel="stylesheet" href="{% static 'css/base.css' %}">
     <link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
     <link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
     <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
     <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
 </head>
 </head>
@@ -256,10 +256,10 @@
             </div>
             </div>
         </div>
         </div>
     </nav>
     </nav>
-	<div class="container wrapper">
+    <div class="container wrapper">
         {% if settings.BANNER_TOP %}
         {% if settings.BANNER_TOP %}
             <div class="alert alert-info text-center" role="alert">
             <div class="alert alert-info text-center" role="alert">
-				{{ settings.BANNER_TOP|safe }}
+                {{ settings.BANNER_TOP|safe }}
             </div>
             </div>
         {% endif %}
         {% endif %}
         {% if settings.MAINTENANCE_MODE %}
         {% if settings.MAINTENANCE_MODE %}
@@ -268,24 +268,24 @@
                 <p>NetBox is currently in maintenance mode. Functionality may be limited.</p>
                 <p>NetBox is currently in maintenance mode. Functionality may be limited.</p>
             </div>
             </div>
         {% endif %}
         {% endif %}
-	    {% for message in messages %}
-	    	<div class="alert alert-{{ message.tags }} alert-dismissable" role="alert">
-	    		<button type="button" class="close" data-dismiss="alert" aria-label="Close">
-	    			<span aria-hidden="true">&times;</span>
-	    		</button>
-	    		{{ message|safe }}
-	    	</div>
-	    {% endfor %}
-		{% block content %}{% endblock %}
+        {% for message in messages %}
+            <div class="alert alert-{{ message.tags }} alert-dismissable" role="alert">
+                <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+                {{ message }}
+            </div>
+        {% endfor %}
+        {% block content %}{% endblock %}
         <div class="push"></div>
         <div class="push"></div>
- 		{% if settings.BANNER_BOTTOM %}
-        	<div class="alert alert-info text-center banner-bottom" role="alert">
+         {% if settings.BANNER_BOTTOM %}
+            <div class="alert alert-info text-center banner-bottom" role="alert">
                  {{ settings.BANNER_BOTTOM|safe }}
                  {{ settings.BANNER_BOTTOM|safe }}
             </div>
             </div>
         {% endif %}
         {% endif %}
-	</div>
-	<footer class="footer">
-		<div class="container">
+    </div>
+    <footer class="footer">
+        <div class="container">
             <div class="row">
             <div class="row">
                 <div class="col-xs-4">
                 <div class="col-xs-4">
                     <p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
                     <p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
@@ -302,8 +302,8 @@
                     </p>
                     </p>
                 </div>
                 </div>
             </div>
             </div>
-		</div>
-	</footer>
+        </div>
+    </footer>
 <script type="text/javascript">
 <script type="text/javascript">
     var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
     var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
 </script>
 </script>

+ 20 - 34
netbox/templates/dcim/device.html

@@ -194,35 +194,6 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         {% endif %}
         {% endif %}
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>IP Addresses</strong>
-            </div>
-            {% if ip_addresses %}
-                <table class="table table-hover panel-body">
-                    {% for ip in ip_addresses %}
-                        {% include 'dcim/inc/ipaddress.html' %}
-                    {% endfor %}
-                </table>
-            {% elif interfaces or mgmt_interfaces %}
-                <div class="panel-body text-muted">
-                    None assigned
-                </div>
-            {% else %}
-                <div class="panel-body">
-                    <a href="{% url 'dcim:interface_add' pk=device.pk %}">Create an interface</a> to assign an IP.
-                </div>
-            {% endif %}
-            {% if perms.ipam.add_ipaddress %}
-                {% if interfaces or mgmt_interfaces %}
-                    <div class="panel-footer text-right">
-                        <a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign IP address
-                        </a>
-                    </div>
-                {% endif %}
-            {% endif %}
-        </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Services</strong>
                 <strong>Services</strong>
@@ -250,7 +221,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Critical Connections</strong>
                 <strong>Critical Connections</strong>
             </div>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body component-list">
                 {% for iface in mgmt_interfaces %}
                 {% for iface in mgmt_interfaces %}
                     {% include 'dcim/inc/interface.html' with icon='wrench' %}
                     {% include 'dcim/inc/interface.html' with icon='wrench' %}
                 {% empty %}
                 {% empty %}
@@ -375,7 +346,7 @@
                         {% endif %}
                         {% endif %}
                     </div>
                     </div>
                 </div>
                 </div>
-                <table class="table table-hover panel-body">
+                <table class="table table-hover panel-body component-list">
                     {% for devicebay in device_bays %}
                     {% for devicebay in device_bays %}
                         {% include 'dcim/inc/devicebay.html' with selectable=True %}
                         {% include 'dcim/inc/devicebay.html' with selectable=True %}
                     {% empty %}
                     {% empty %}
@@ -416,6 +387,9 @@
                 <div class="panel-heading">
                 <div class="panel-heading">
                     <strong>Interfaces</strong>
                     <strong>Interfaces</strong>
                     <div class="pull-right">
                     <div class="pull-right">
+                        <button class="btn btn-default btn-xs toggle-ips" selected="selected">
+                            <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
+                        </button>
                         {% if perms.dcim.change_interface and interfaces|length > 1 %}
                         {% if perms.dcim.change_interface and interfaces|length > 1 %}
                             <button class="btn btn-default btn-xs toggle">
                             <button class="btn btn-default btn-xs toggle">
                                 <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
                                 <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
@@ -428,7 +402,7 @@
                         {% endif %}
                         {% endif %}
                     </div>
                     </div>
                 </div>
                 </div>
-                <table class="table table-hover panel-body">
+                <table id="interfaces_table" class="table table-hover panel-body component-list">
                     {% for iface in interfaces %}
                     {% for iface in interfaces %}
                         {% include 'dcim/inc/interface.html' with selectable=True %}
                         {% include 'dcim/inc/interface.html' with selectable=True %}
                     {% empty %}
                     {% empty %}
@@ -485,7 +459,7 @@
                         {% endif %}
                         {% endif %}
                     </div>
                     </div>
                 </div>
                 </div>
-                <table class="table table-hover panel-body">
+                <table class="table table-hover panel-body component-list">
                     {% for csp in cs_ports %}
                     {% for csp in cs_ports %}
                         {% include 'dcim/inc/consoleserverport.html' with selectable=True %}
                         {% include 'dcim/inc/consoleserverport.html' with selectable=True %}
                     {% empty %}
                     {% empty %}
@@ -537,7 +511,7 @@
                         {% endif %}
                         {% endif %}
                     </div>
                     </div>
                 </div>
                 </div>
-                <table class="table table-hover panel-body">
+                <table class="table table-hover panel-body component-list">
                     {% for po in power_outlets %}
                     {% for po in power_outlets %}
                         {% include 'dcim/inc/poweroutlet.html' with selectable=True %}
                         {% include 'dcim/inc/poweroutlet.html' with selectable=True %}
                     {% empty %}
                     {% empty %}
@@ -628,6 +602,18 @@ $(".powerport-toggle").click(function() {
 $(".interface-toggle").click(function() {
 $(".interface-toggle").click(function() {
     return toggleConnection($(this), "dcim/interface-connections/");
     return toggleConnection($(this), "dcim/interface-connections/");
 });
 });
+// Toggle the display of IP addresses under interfaces
+$('button.toggle-ips').click(function() {
+    var selected = $(this).attr('selected');
+    if (selected) {
+        $('#interfaces_table tr.ipaddress').hide();
+    } else {
+        $('#interfaces_table tr.ipaddress').show();
+    }
+    $(this).attr('selected', !selected);
+    $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
+    return false;
+});
 </script>
 </script>
 <script src="{% static 'js/graphs.js' %}"></script>
 <script src="{% static 'js/graphs.js' %}"></script>
 <script src="{% static 'js/secrets.js' %}"></script>
 <script src="{% static 'js/secrets.js' %}"></script>

+ 2 - 3
netbox/templates/dcim/inc/consoleport.html

@@ -1,4 +1,4 @@
-<tr{% if cp.cs_port and not cp.connection_status %} class="info"{% endif %}>
+<tr class="consoleport{% if cp.cs_port and not cp.connection_status %} info{% endif %}">
     {% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
     {% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
         <td class="pk">
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ cp.pk }}" />
             <input name="pk" type="checkbox" value="{{ cp.pk }}" />
@@ -7,7 +7,6 @@
     <td>
     <td>
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp.name }}
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp.name }}
     </td>
     </td>
-    <td></td>
     {% if cp.cs_port %}
     {% if cp.cs_port %}
         <td>
         <td>
             <a href="{% url 'dcim:device' pk=cp.cs_port.device.pk %}">{{ cp.cs_port.device }}</a>
             <a href="{% url 'dcim:device' pk=cp.cs_port.device.pk %}">{{ cp.cs_port.device }}</a>
@@ -20,7 +19,7 @@
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
         </td>
         </td>
     {% endif %}
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_consoleport %}
         {% if perms.dcim.change_consoleport %}
             {% if cp.cs_port %}
             {% if cp.cs_port %}
                 {% if cp.connection_status %}
                 {% if cp.connection_status %}

+ 2 - 2
netbox/templates/dcim/inc/consoleserverport.html

@@ -1,4 +1,4 @@
-<tr{% if csp.connected_console and not csp.connected_console.connection_status %} class="info"{% endif %}>
+<tr class="consoleserverport{% if csp.connected_console and not csp.connected_console.connection_status %} info{% endif %}">
     {% if selectable and perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
     {% if selectable and perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
         <td class="pk">
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ csp.pk }}" />
             <input name="pk" type="checkbox" value="{{ csp.pk }}" />
@@ -19,7 +19,7 @@
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
         </td>
         </td>
     {% endif %}
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_consoleserverport %}
         {% if perms.dcim.change_consoleserverport %}
             {% if csp.connected_console %}
             {% if csp.connected_console %}
                 {% if csp.connected_console.connection_status %}
                 {% if csp.connected_console.connection_status %}

+ 2 - 2
netbox/templates/dcim/inc/devicebay.html

@@ -1,4 +1,4 @@
-<tr>
+<tr class="devicebay">
     {% if selectable and perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
     {% if selectable and perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
         <td class="pk">
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
             <input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
@@ -19,7 +19,7 @@
             <span class="text-muted">Vacant</span>
             <span class="text-muted">Vacant</span>
         </td>
         </td>
     {% endif %}
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_devicebay %}
         {% if perms.dcim.change_devicebay %}
             {% if devicebay.installed_device %}
             {% if devicebay.installed_device %}
                 <a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
                 <a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">

+ 52 - 10
netbox/templates/dcim/inc/interface.html

@@ -1,4 +1,4 @@
-<tr{% if iface.connection and not iface.connection.connection_status %} class="info"{% endif %}>
+<tr class="interface{% if iface.connection and not iface.connection.connection_status %} info{% endif %}">
     {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
     {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
         <td class="pk">
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ iface.pk }}" />
             <input name="pk" type="checkbox" value="{{ iface.pk }}" />
@@ -16,10 +16,9 @@
             <br /><small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
             <br /><small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
         {% endif %}
         {% endif %}
     </td>
     </td>
-    <td>
-        <small>{{ iface.mac_address|default:'' }}</small>
-    </td>
-    {% if iface.is_virtual %}
+    {% if iface.is_lag %}
+        <td colspan="2" class="text-muted">LAG interface</td>
+    {% elif iface.is_virtual %}
         <td colspan="2" class="text-muted">Virtual interface</td>
         <td colspan="2" class="text-muted">Virtual interface</td>
     {% elif iface.connection %}
     {% elif iface.connection %}
         {% with iface.connected_interface as connected_iface %}
         {% with iface.connected_interface as connected_iface %}
@@ -51,7 +50,7 @@
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
         </td>
         </td>
     {% endif %}
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if show_graphs %}
         {% if show_graphs %}
             {% if iface.circuit_termination or iface.connection %}
             {% if iface.circuit_termination or iface.connection %}
                 <button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface_graphs' pk=iface.pk %}" title="Show graphs">
                 <button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface_graphs' pk=iface.pk %}" title="Show graphs">
@@ -59,6 +58,11 @@
                 </button>
                 </button>
             {% endif %}
             {% endif %}
         {% endif %}
         {% endif %}
+        {% if perms.ipam.add_ipaddress %}
+            <a href="{% url 'ipam:ipaddress_add' %}?interface_site={{ device.site.pk }}&interface_rack={{ device.rack.pk }}&interface_device={{ device.pk }}&interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
+                <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
+            </a>
+        {% endif %}
         {% if perms.dcim.change_interface %}
         {% if perms.dcim.change_interface %}
             {% if not iface.is_virtual %}
             {% if not iface.is_virtual %}
                 {% if iface.connection %}
                 {% if iface.connection %}
@@ -71,19 +75,19 @@
                             <i class="fa fa-plug" aria-hidden="true"></i>
                             <i class="fa fa-plug" aria-hidden="true"></i>
                         </a>
                         </a>
                     {% endif %}
                     {% endif %}
-                    <a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
-                        <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
+                    <a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Disconnect">
+                        <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     </a>
                     </a>
                 {% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
                 {% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
                     <button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
                     <button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
                         <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
                         <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
                     </button>
                     </button>
                     <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
                     <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
-                        <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
+                        <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     </a>
                     </a>
                 {% else %}
                 {% else %}
                     <a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
                     <a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
-                        <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
+                        <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                     </a>
                     </a>
                 {% endif %}
                 {% endif %}
             {% endif %}
             {% endif %}
@@ -104,3 +108,41 @@
         {% endif %}
         {% endif %}
     </td>
     </td>
 </tr>
 </tr>
+{% for ip in iface.ip_addresses.all %}
+    <tr class="ipaddress">
+        {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
+            <td></td>
+        {% endif %}
+        <td colspan="2">
+            <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
+            {% if ip.description %}
+                <i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
+            {% endif %}
+            {% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
+                <span class="label label-success">Primary</span>
+            {% endif %}
+        </td>
+        <td class="text-right">
+            {% if ip.vrf %}
+                <a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
+            {% else %}
+                <span class="text-muted">Global</span>
+            {% endif %}
+        </td>
+        <td>
+            <span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
+        </td>
+        <td class="text-right">
+            {% if perms.ipam.edit_ipaddress %}
+                <a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
+                    <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
+                </a>
+            {% endif %}
+            {% if perms.ipam.delete_ipaddress %}
+                <a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                    <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
+                </a>
+            {% endif %}
+        </td>
+    </tr>
+{% endfor %}

+ 0 - 21
netbox/templates/dcim/inc/ipaddress.html

@@ -1,21 +0,0 @@
-<tr>
-    <td>
-        <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
-    </td>
-    <td>
-        {{ ip.vrf|default:"Global" }}
-    </td>
-    <td>{{ ip.interface }}</td>
-    <td>
-        {% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
-            <span class="label label-success">Primary</span>
-        {% endif %}
-    </td>
-    <td class="text-right">
-        {% if perms.ipam.delete_ipaddress %}
-            <a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
-                <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
-            </a>
-        {% endif %}
-    </td>
-</tr>

+ 2 - 2
netbox/templates/dcim/inc/poweroutlet.html

@@ -1,4 +1,4 @@
-<tr{% if po.connected_port and not po.connected_port.connection_status %} class="info"{% endif %}>
+<tr class="poweroutlet{% if po.connected_port and not po.connected_port.connection_status %} info{% endif %}">
     {% if selectable and perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
     {% if selectable and perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
         <td class="pk">
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ po.pk }}" />
             <input name="pk" type="checkbox" value="{{ po.pk }}" />
@@ -19,7 +19,7 @@
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
         </td>
         </td>
     {% endif %}
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_poweroutlet %}
         {% if perms.dcim.change_poweroutlet %}
             {% if po.connected_port %}
             {% if po.connected_port %}
                 {% if po.connected_port.connection_status %}
                 {% if po.connected_port.connection_status %}

+ 2 - 3
netbox/templates/dcim/inc/powerport.html

@@ -1,4 +1,4 @@
-<tr{% if pp.power_outlet and not pp.connection_status %} class="info"{% endif %}>
+<tr class="powerport{% if pp.power_outlet and not pp.connection_status %} info{% endif %}">
     {% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
     {% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
         <td class="pk">
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ pp.pk }}" />
             <input name="pk" type="checkbox" value="{{ pp.pk }}" />
@@ -7,7 +7,6 @@
     <td>
     <td>
         <i class="fa fa-fw fa-bolt"></i> {{ pp.name }}
         <i class="fa fa-fw fa-bolt"></i> {{ pp.name }}
     </td>
     </td>
-    <td></td>
     {% if pp.power_outlet %}
     {% if pp.power_outlet %}
         <td>
         <td>
             <a href="{% url 'dcim:device' pk=pp.power_outlet.device.pk %}">{{ pp.power_outlet.device }}</a>
             <a href="{% url 'dcim:device' pk=pp.power_outlet.device.pk %}">{{ pp.power_outlet.device }}</a>
@@ -20,7 +19,7 @@
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
         </td>
         </td>
     {% endif %}
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_powerport %}
         {% if perms.dcim.change_powerport %}
             {% if pp.power_outlet %}
             {% if pp.power_outlet %}
                 {% if pp.connection_status %}
                 {% if pp.connection_status %}

+ 0 - 6
netbox/templates/ipam/ipaddress.html

@@ -98,14 +98,8 @@
                     <td>
                     <td>
                         {% if ipaddress.interface %}
                         {% if ipaddress.interface %}
                             <span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
                             <span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
-                            {% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
-                                <a href="{% url 'ipam:ipaddress_remove' pk=ipaddress.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-remove"></i> Remove</a>
-                            {% endif %}
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
-                            {% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
-                                <a href="{% url 'ipam:ipaddress_assign' pk=ipaddress.pk %}" class="btn btn-xs btn-primary"><i class="glyphicon glyphicon-plus"></i> Assign</a>
-                            {% endif %}
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>

+ 10 - 2
netbox/templates/ipam/ipaddress_bulk_add.html

@@ -10,13 +10,21 @@
 
 
 {% block form %}
 {% block form %}
     <div class="panel panel-default">
     <div class="panel panel-default">
-        <div class="panel-heading"><strong>IP Address</strong></div>
+        <div class="panel-heading"><strong>IP Addresses</strong></div>
         <div class="panel-body">
         <div class="panel-body">
-            {% render_field form.address %}
+            {% render_field form.address_pattern %}
             {% render_field form.vrf %}
             {% render_field form.vrf %}
             {% render_field form.tenant %}
             {% render_field form.tenant %}
             {% render_field form.status %}
             {% render_field form.status %}
             {% render_field form.description %}
             {% render_field form.description %}
         </div>
         </div>
     </div>
     </div>
+    {% if form.custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading"><strong>Custom Fields</strong></div>
+            <div class="panel-body">
+                {% render_custom_fields form %}
+            </div>
+        </div>
+    {% endif %}
 {% endblock %}
 {% endblock %}

+ 11 - 30
netbox/templates/ipam/ipaddress_edit.html

@@ -16,39 +16,20 @@
             {% render_field form.vrf %}
             {% render_field form.vrf %}
             {% render_field form.tenant %}
             {% render_field form.tenant %}
             {% render_field form.status %}
             {% render_field form.status %}
-            {% if obj.pk %}
-                <div class="form-group">
-                    <label class="col-md-3 control-label">Device</label>
-                    <div class="col-md-9">
-                        <p class="form-control-static">
-                            {% if obj.interface %}
-                                <a href="{% url 'dcim:device' pk=obj.interface.device.pk %}">{{ obj.interface.device }}</a>
-                                <a href="{% url 'ipam:ipaddress_remove' pk=obj.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-remove"></i> Remove</a>
-                            {% else %}
-                                <span class="text-muted">None</span>
-                                {% if obj.pk %}
-                                    <a href="{% url 'ipam:ipaddress_assign' pk=obj.pk %}" class="btn btn-xs btn-primary"><i class="glyphicon glyphicon-plus"></i> Assign</a>
-                                {% endif %}
-                            {% endif %}
-                        </p>
-                    </div>
-                </div>
-                <div class="form-group">
-                    <label class="col-md-3 control-label">Interface</label>
-                    <div class="col-md-9">
-                        <p class="form-control-static">
-                            {% if obj.interface %}
-                                {{ obj.interface }}
-                            {% else %}
-                                <span class="text-muted">None</span>
-                            {% endif %}
-                        </p>
-                    </div>
-                </div>
-            {% endif %}
             {% render_field form.description %}
             {% render_field form.description %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading">
+            <strong>Interface Assignment</strong>
+        </div>
+        <div class="panel-body">
+            {% render_field form.interface_site %}
+            {% render_field form.interface_rack %}
+            {% render_field form.interface_device %}
+            {% render_field form.interface %}
+        </div>
+    </div>
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
         <div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
         <div class="panel-body">
         <div class="panel-body">

+ 3 - 3
netbox/templates/utilities/confirmation_form.html

@@ -6,13 +6,13 @@
 	<div class="col-md-6 col-md-offset-3">
 	<div class="col-md-6 col-md-offset-3">
         <form action="." method="post" class="form">
         <form action="." method="post" class="form">
         {% csrf_token %}
         {% csrf_token %}
+        {% for field in form.hidden_fields %}
+            {{ field }}
+        {% endfor %}
             <div class="panel panel-{{ panel_class|default:"danger" }}">
             <div class="panel panel-{{ panel_class|default:"danger" }}">
                 <div class="panel-heading">{% block title %}{% endblock %}</div>
                 <div class="panel-heading">{% block title %}{% endblock %}</div>
                 <div class="panel-body">
                 <div class="panel-body">
                     {% block message %}<p>Are you sure?</p>{% endblock %}
                     {% block message %}<p>Are you sure?</p>{% endblock %}
-                    {% for field in form.hidden_fields %}
-                        {{ field }}
-                    {% endfor %}
                     <div class="form-group">
                     <div class="form-group">
                         <div class="checkbox{% if form.confirm.errors %} has-error{% endif %}">
                         <div class="checkbox{% if form.confirm.errors %} has-error{% endif %}">
                             <label for="{{ form.confirm.id_for_label }}">
                             <label for="{{ form.confirm.id_for_label }}">

+ 1 - 2
netbox/tenancy/views.py

@@ -29,7 +29,7 @@ class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
     model = TenantGroup
     model = TenantGroup
     form_class = forms.TenantGroupForm
     form_class = forms.TenantGroupForm
 
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('tenancy:tenantgroup_list')
         return reverse('tenancy:tenantgroup_list')
 
 
 
 
@@ -81,7 +81,6 @@ class TenantEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'tenancy.change_tenant'
     permission_required = 'tenancy.change_tenant'
     model = Tenant
     model = Tenant
     form_class = forms.TenantForm
     form_class = forms.TenantForm
-    fields_initial = ['group']
     template_name = 'tenancy/tenant_edit.html'
     template_name = 'tenancy/tenant_edit.html'
     default_return_url = 'tenancy:tenant_list'
     default_return_url = 'tenancy:tenant_list'
 
 

+ 9 - 4
netbox/utilities/forms.py

@@ -437,15 +437,20 @@ class BootstrapMixin(forms.BaseForm):
                 field.widget.attrs['placeholder'] = field.label
                 field.widget.attrs['placeholder'] = field.label
 
 
 
 
-class ConfirmationForm(BootstrapMixin, forms.Form):
+class ReturnURLForm(forms.Form):
     """
     """
-    A generic confirmation form. The form is not valid unless the confirm field is checked. An optional return_url can
-    be specified to direct the user to a specific URL after the action has been taken.
+    Provides a hidden return URL field to control where the user is directed after the form is submitted.
     """
     """
-    confirm = forms.BooleanField(required=True)
     return_url = forms.CharField(required=False, widget=forms.HiddenInput())
     return_url = forms.CharField(required=False, widget=forms.HiddenInput())
 
 
 
 
+class ConfirmationForm(BootstrapMixin, ReturnURLForm):
+    """
+    A generic confirmation form. The form is not valid unless the confirm field is checked.
+    """
+    confirm = forms.BooleanField(required=True)
+
+
 class BulkEditForm(forms.Form):
 class BulkEditForm(forms.Form):
 
 
     def __init__(self, model, *args, **kwargs):
     def __init__(self, model, *args, **kwargs):

+ 53 - 49
netbox/utilities/views.py

@@ -12,7 +12,9 @@ from django.forms import CharField, ModelMultipleChoiceField, MultipleHiddenInpu
 from django.http import HttpResponse
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import TemplateSyntaxError
 from django.template import TemplateSyntaxError
+from django.utils.html import escape
 from django.utils.http import is_safe_url
 from django.utils.http import is_safe_url
+from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
 
 
 from extras.forms import CustomFieldForm
 from extras.forms import CustomFieldForm
@@ -39,6 +41,23 @@ class CustomFieldQueryset:
             yield obj
             yield obj
 
 
 
 
+class GetReturnURLMixin(object):
+    """
+    Provides logic for determining where a user should be redirected after processing a form.
+    """
+    default_return_url = None
+
+    def get_return_url(self, request, obj):
+        query_param = request.GET.get('return_url')
+        if query_param and is_safe_url(url=query_param, host=request.get_host()):
+            return query_param
+        elif obj.pk and hasattr(obj, 'get_absolute_url'):
+            return obj.get_absolute_url()
+        elif self.default_return_url is not None:
+            return reverse(self.default_return_url)
+        return reverse('home')
+
+
 class ObjectListView(View):
 class ObjectListView(View):
     """
     """
     List a series of objects.
     List a series of objects.
@@ -128,21 +147,18 @@ class ObjectListView(View):
         return {}
         return {}
 
 
 
 
-class ObjectEditView(View):
+class ObjectEditView(GetReturnURLMixin, View):
     """
     """
     Create or edit a single object.
     Create or edit a single object.
 
 
     model: The model of the object being edited
     model: The model of the object being edited
     form_class: The form used to create or edit the object
     form_class: The form used to create or edit the object
-    fields_initial: A set of fields that will be prepopulated in the form from the request parameters
     template_name: The name of the template
     template_name: The name of the template
     default_return_url: The name of the URL used to display a list of this object type
     default_return_url: The name of the URL used to display a list of this object type
     """
     """
     model = None
     model = None
     form_class = None
     form_class = None
-    fields_initial = []
     template_name = 'utilities/obj_edit.html'
     template_name = 'utilities/obj_edit.html'
-    default_return_url = 'home'
 
 
     def get_object(self, kwargs):
     def get_object(self, kwargs):
         # Look up object by slug or PK. Return None if neither was provided.
         # Look up object by slug or PK. Return None if neither was provided.
@@ -157,24 +173,19 @@ class ObjectEditView(View):
         # given some parameter from the request URL.
         # given some parameter from the request URL.
         return obj
         return obj
 
 
-    def get_return_url(self, obj):
-        # Determine where to redirect the user after updating an object (or aborting an update).
-        if obj.pk and hasattr(obj, 'get_absolute_url'):
-            return obj.get_absolute_url()
-        return reverse(self.default_return_url)
-
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
 
 
         obj = self.get_object(kwargs)
         obj = self.get_object(kwargs)
         obj = self.alter_obj(obj, request, args, kwargs)
         obj = self.alter_obj(obj, request, args, kwargs)
-        initial_data = {k: request.GET[k] for k in self.fields_initial if k in request.GET}
+        # 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.form_class(instance=obj, initial=initial_data)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'obj': obj,
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             'obj_type': self.model._meta.verbose_name,
             'form': form,
             'form': form,
-            'return_url': self.get_return_url(obj),
+            'return_url': self.get_return_url(request, obj),
         })
         })
 
 
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
@@ -194,10 +205,10 @@ class ObjectEditView(View):
             msg = u'Created ' if obj_created else u'Modified '
             msg = u'Created ' if obj_created else u'Modified '
             msg += self.model._meta.verbose_name
             msg += self.model._meta.verbose_name
             if hasattr(obj, 'get_absolute_url'):
             if hasattr(obj, 'get_absolute_url'):
-                msg = u'{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), obj)
+                msg = u'{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
             else:
             else:
-                msg = u'{} {}'.format(msg, obj)
-            messages.success(request, msg)
+                msg = u'{} {}'.format(msg, escape(obj))
+            messages.success(request, mark_safe(msg))
             if obj_created:
             if obj_created:
                 UserAction.objects.log_create(request.user, obj, msg)
                 UserAction.objects.log_create(request.user, obj, msg)
             else:
             else:
@@ -205,17 +216,22 @@ class ObjectEditView(View):
 
 
             if '_addanother' in request.POST:
             if '_addanother' in request.POST:
                 return redirect(request.path)
                 return redirect(request.path)
-            return redirect(self.get_return_url(obj))
+
+            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()):
+                return redirect(return_url)
+            else:
+                return redirect(self.get_return_url(request, obj))
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'obj': obj,
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             'obj_type': self.model._meta.verbose_name,
             'form': form,
             'form': form,
-            'return_url': self.get_return_url(obj),
+            'return_url': self.get_return_url(request, obj),
         })
         })
 
 
 
 
-class ObjectDeleteView(View):
+class ObjectDeleteView(GetReturnURLMixin, View):
     """
     """
     Delete a single object.
     Delete a single object.
 
 
@@ -225,7 +241,6 @@ class ObjectDeleteView(View):
     """
     """
     model = None
     model = None
     template_name = 'utilities/obj_delete.html'
     template_name = 'utilities/obj_delete.html'
-    default_return_url = 'home'
 
 
     def get_object(self, kwargs):
     def get_object(self, kwargs):
         # Look up object by slug if one has been provided. Otherwise, use PK.
         # Look up object by slug if one has been provided. Otherwise, use PK.
@@ -234,24 +249,16 @@ class ObjectDeleteView(View):
         else:
         else:
             return get_object_or_404(self.model, pk=kwargs['pk'])
             return get_object_or_404(self.model, pk=kwargs['pk'])
 
 
-    def get_return_url(self, obj):
-        if obj.pk and hasattr(obj, 'get_absolute_url'):
-            return obj.get_absolute_url()
-        return reverse(self.default_return_url)
-
     def get(self, request, **kwargs):
     def get(self, request, **kwargs):
 
 
         obj = self.get_object(kwargs)
         obj = self.get_object(kwargs)
-        initial_data = {
-            'return_url': request.GET.get('return_url'),
-        }
-        form = ConfirmationForm(initial=initial_data)
+        form = ConfirmationForm(initial=request.GET)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'obj': obj,
             'obj': obj,
             'form': form,
             'form': form,
             'obj_type': self.model._meta.verbose_name,
             'obj_type': self.model._meta.verbose_name,
-            'return_url': request.GET.get('return_url') or self.get_return_url(obj),
+            'return_url': self.get_return_url(request, obj),
         })
         })
 
 
     def post(self, request, **kwargs):
     def post(self, request, **kwargs):
@@ -270,17 +277,17 @@ class ObjectDeleteView(View):
             messages.success(request, msg)
             messages.success(request, msg)
             UserAction.objects.log_delete(request.user, obj, msg)
             UserAction.objects.log_delete(request.user, obj, msg)
 
 
-            return_url = form.cleaned_data['return_url']
-            if return_url and is_safe_url(url=return_url, host=request.get_host()):
+            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()):
                 return redirect(return_url)
                 return redirect(return_url)
             else:
             else:
-                return redirect(self.get_return_url(obj))
+                return redirect(self.get_return_url(request, obj))
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'obj': obj,
             'obj': obj,
             'form': form,
             'form': form,
             'obj_type': self.model._meta.verbose_name,
             'obj_type': self.model._meta.verbose_name,
-            'return_url': request.GET.get('return_url') or self.get_return_url(obj),
+            'return_url': self.get_return_url(request, obj),
         })
         })
 
 
 
 
@@ -289,12 +296,12 @@ class BulkAddView(View):
     Create new objects in bulk.
     Create new objects in bulk.
 
 
     form: Form class
     form: Form class
-    model: The model of the objects being created
+    model_form: The ModelForm used to create individual objects
     template_name: The name of the template
     template_name: The name of the template
     default_return_url: Name of the URL to which the user is redirected after creating the objects
     default_return_url: Name of the URL to which the user is redirected after creating the objects
     """
     """
     form = None
     form = None
-    model = None
+    model_form = None
     template_name = None
     template_name = None
     default_return_url = 'home'
     default_return_url = 'home'
 
 
@@ -303,47 +310,44 @@ class BulkAddView(View):
         form = self.form()
         form = self.form()
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
-            'obj_type': self.model._meta.verbose_name,
+            'obj_type': self.model_form._meta.model._meta.verbose_name,
             'form': form,
             'form': form,
             'return_url': reverse(self.default_return_url),
             'return_url': reverse(self.default_return_url),
         })
         })
 
 
     def post(self, request):
     def post(self, request):
 
 
+        model = self.model_form._meta.model
         form = self.form(request.POST)
         form = self.form(request.POST)
         if form.is_valid():
         if form.is_valid():
 
 
-            # The first field will be used as the pattern
-            field_names = list(form.fields.keys())
-            pattern_field = field_names[0]
+            # Read the pattern field and target from the form's pattern_map
+            pattern_field, pattern_target = form.pattern_map
             pattern = form.cleaned_data[pattern_field]
             pattern = form.cleaned_data[pattern_field]
-
-            # All other fields will be copied as object attributes
-            kwargs = {k: form.cleaned_data[k] for k in field_names[1:]}
+            model_form_data = form.cleaned_data
 
 
             new_objs = []
             new_objs = []
             try:
             try:
                 with transaction.atomic():
                 with transaction.atomic():
                     for value in pattern:
                     for value in pattern:
-                        obj = self.model(**kwargs)
-                        setattr(obj, pattern_field, value)
-                        obj.full_clean()
-                        obj.save()
+                        model_form_data[pattern_target] = value
+                        model_form = self.model_form(model_form_data)
+                        obj = model_form.save()
                         new_objs.append(obj)
                         new_objs.append(obj)
             except ValidationError as e:
             except ValidationError as e:
                 form.add_error(None, e)
                 form.add_error(None, e)
 
 
             if not form.errors:
             if not form.errors:
-                msg = u"Added {} {}".format(len(new_objs), self.model._meta.verbose_name_plural)
+                msg = u"Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
                 messages.success(request, msg)
                 messages.success(request, msg)
-                UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(self.model), msg)
+                UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg)
                 if '_addanother' in request.POST:
                 if '_addanother' in request.POST:
                     return redirect(request.path)
                     return redirect(request.path)
                 return redirect(self.default_return_url)
                 return redirect(self.default_return_url)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
-            'obj_type': self.model._meta.verbose_name,
+            'obj_type': model._meta.verbose_name,
             'return_url': reverse(self.default_return_url),
             'return_url': reverse(self.default_return_url),
         })
         })
 
 

+ 1 - 1
requirements.txt

@@ -10,7 +10,7 @@ djangorestframework>=3.5.0
 graphviz>=0.4.10
 graphviz>=0.4.10
 Markdown>=2.6.7
 Markdown>=2.6.7
 natsort>=5.0.0
 natsort>=5.0.0
-ncclient==0.5.2
+ncclient==0.5.3
 netaddr==0.7.18
 netaddr==0.7.18
 paramiko>=2.0.0
 paramiko>=2.0.0
 psycopg2>=2.6.1
 psycopg2>=2.6.1

+ 6 - 2
upgrade.sh

@@ -15,8 +15,12 @@ if [ "$(whoami)" = "root" ]; then
 	PREFIX=""
 	PREFIX=""
 fi
 fi
 
 
+# Fall back to pip3 if pip is missing
+PIP="pip"
+type $PIP >/dev/null 2>&1 || PIP="pip3"
+
 # Install any new Python packages
 # Install any new Python packages
-COMMAND="${PREFIX}pip install -r requirements.txt --upgrade"
+COMMAND="${PREFIX}${PIP} install -r requirements.txt --upgrade"
 echo "Updating required Python packages ($COMMAND)..."
 echo "Updating required Python packages ($COMMAND)..."
 eval $COMMAND
 eval $COMMAND
 
 
@@ -24,4 +28,4 @@ eval $COMMAND
 ./netbox/manage.py migrate
 ./netbox/manage.py migrate
 
 
 # Collect static files
 # Collect static files
-./netbox/manage.py collectstatic --noinput
+./netbox/manage.py collectstatic --no-input