Browse Source

Merge branch 'develop' into v2-develop

Conflicts:
	netbox/dcim/forms.py
	netbox/dcim/views.py
	netbox/ipam/forms.py
	netbox/templates/_base.html
	netbox/utilities/views.py
Jeremy Stretch 8 years ago
parent
commit
b01bf6089c

+ 2 - 1
docs/installation/netbox.md

@@ -20,7 +20,8 @@ Python 3:
 
 ```no-highlight
 # 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
 ```
 
 Python 2:

+ 2 - 4
netbox/circuits/views.py

@@ -95,7 +95,7 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
     model = CircuitType
     form_class = forms.CircuitTypeForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('circuits:circuittype_list')
 
 
@@ -142,7 +142,6 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'circuits.change_circuit'
     model = Circuit
     form_class = forms.CircuitForm
-    fields_initial = ['provider']
     template_name = 'circuits/circuit_edit.html'
     default_return_url = 'circuits:circuit_list'
 
@@ -230,7 +229,6 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'circuits.change_circuittermination'
     model = CircuitTermination
     form_class = forms.CircuitTerminationForm
-    fields_initial = ['term_side']
     template_name = 'circuits/circuittermination_edit.html'
 
     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'])
         return obj
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         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)
 
         # Limit LAG choices to interfaces which belong to the parent device.
+        device = None
         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:
             self.fields['lag'].choices = []
@@ -1685,36 +1692,6 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 #
-# 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
-
-
-#
 # Inventory items
 #
 

+ 0 - 1
netbox/dcim/urls.py

@@ -121,7 +121,6 @@ urlpatterns = [
     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+)/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<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
     url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),

+ 13 - 60
netbox/dcim/views.py

@@ -13,7 +13,7 @@ from django.urls import reverse
 from django.utils.http import urlencode
 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 extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from utilities.forms import ConfirmationForm
@@ -124,13 +124,13 @@ class ComponentCreateView(View):
 
 class ComponentEditView(ObjectEditView):
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.device.get_absolute_url()
 
 
 class ComponentDeleteView(ObjectDeleteView):
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.device.get_absolute_url()
 
 
@@ -149,7 +149,7 @@ class RegionEditView(PermissionRequiredMixin, ObjectEditView):
     model = Region
     form_class = forms.RegionForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:region_list')
 
 
@@ -242,7 +242,7 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
     model = RackGroup
     form_class = forms.RackGroupForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:rackgroup_list')
 
 
@@ -268,7 +268,7 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
     model = RackRole
     form_class = forms.RackRoleForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:rackrole_list')
 
 
@@ -379,7 +379,7 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
             obj.user = request.user
         return obj
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.rack.get_absolute_url()
 
 
@@ -387,7 +387,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_rackreservation'
     model = RackReservation
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.rack.get_absolute_url()
 
 
@@ -412,7 +412,7 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
     model = Manufacturer
     form_class = forms.ManufacturerForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:manufacturer_list')
 
 
@@ -632,7 +632,7 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
     model = DeviceRole
     form_class = forms.DeviceRoleForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:devicerole_list')
 
 
@@ -657,7 +657,7 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
     model = Platform
     form_class = forms.PlatformForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('dcim:platform_list')
 
 
@@ -700,19 +700,15 @@ def device(request, pk):
     interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
         .filter(device=device, mgmt_only=False)\
         .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)\
         .filter(device=device, mgmt_only=True)\
         .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(
         DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
         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)
     secrets = device.secrets.all()
 
@@ -743,7 +739,6 @@ def device(request, pk):
         'interfaces': interfaces,
         'mgmt_interfaces': mgmt_interfaces,
         'device_bays': device_bays,
-        'ip_addresses': ip_addresses,
         'services': services,
         'secrets': secrets,
         'related_devices': related_devices,
@@ -755,7 +750,6 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_device'
     model = Device
     form_class = forms.DeviceForm
-    fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
     template_name = 'dcim/device_edit.html'
     default_return_url = 'dcim:device_list'
 
@@ -1568,47 +1562,6 @@ class InterfaceConnectionsListView(ObjectListView):
 
 
 #
-# 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}),
-    })
-
-
-#
 # Inventory items
 #
 

+ 84 - 28
netbox/ipam/forms.py

@@ -6,7 +6,7 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
 from tenancy.models import Tenant
 from utilities.forms import (
     APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
-    SlugField, add_blank_choice,
+    ReturnURLForm, SlugField, add_blank_choice,
 )
 
 from .models import (
@@ -210,28 +210,33 @@ class PrefixFromCSVForm(forms.ModelForm):
         site = self.cleaned_data.get('site')
         vlan_group_name = self.cleaned_data.get('vlan_group_name')
         vlan_vid = self.cleaned_data.get('vlan_vid')
-
-        # Validate VLAN
         vlan_group = None
+        vlan = None
+
+        # Validate VLAN group
         if vlan_group_name:
             try:
                 vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
             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:
-                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:
-                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:
                 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):
 
@@ -302,21 +307,46 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # 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:
         model = IPAddress
-        fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
+        fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'nat_inside', 'description']
         widgets = {
+            'interface': APISelect(api_url='/api/dcim/devices/interfaces/?device_id={{interface_device}}'),
             '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'
 
-        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
             # If the IP is assigned to an interface, populate site/device fields accordingly
             if self.instance.nat_inside.interface:
@@ -340,9 +399,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
                 )
             else:
                 self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
-
         else:
-
             # Initialize nat_device choices if nat_site is set
             if self.is_bound and self.data.get('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'])
             else:
                 self.fields['nat_device'].choices = []
-
             # Initialize nat_inside choices if nat_device is set
             if self.is_bound and self.data.get('nat_device'):
                 self.fields['nat_inside'].queryset = IPAddress.objects.filter(

+ 0 - 2
netbox/ipam/urls.py

@@ -58,8 +58,6 @@ urlpatterns = [
     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+)/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'),
 
     # VLAN groups

+ 4 - 75
netbox/ipam/views.py

@@ -244,7 +244,7 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
     model = RIR
     form_class = forms.RIRForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('ipam:rir_list')
 
 
@@ -370,7 +370,7 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
     model = Role
     form_class = forms.RoleForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('ipam:role_list')
 
 
@@ -464,7 +464,6 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
     model = Prefix
     form_class = forms.PrefixForm
     template_name = 'ipam/prefix_edit.html'
-    fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
     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):
     permission_required = 'ipam.change_ipaddress'
     model = IPAddress
     form_class = forms.IPAddressForm
-    fields_initial = ['address', 'vrf']
     template_name = 'ipam/ipaddress_edit.html'
     default_return_url = 'ipam:ipaddress_list'
 
@@ -718,7 +647,7 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
     model = VLANGroup
     form_class = forms.VLANGroupForm
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return reverse('ipam:vlangroup_list')
 
 
@@ -807,7 +736,7 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
             obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
         return obj
 
-    def get_return_url(self, obj):
+    def get_return_url(self, request, obj):
         return obj.device.get_absolute_url()
 
 

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

@@ -316,6 +316,16 @@ li.occupied + li.available {
     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 */
 .banner-bottom {
     margin-bottom: 50px;

+ 1 - 1
netbox/secrets/views.py

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

+ 18 - 18
netbox/templates/_base.html

@@ -274,10 +274,10 @@
             </div>
         </div>
     </nav>
-	<div class="container wrapper">
+    <div class="container wrapper">
         {% if settings.BANNER_TOP %}
             <div class="alert alert-info text-center" role="alert">
-				{{ settings.BANNER_TOP|safe }}
+                {{ settings.BANNER_TOP|safe }}
             </div>
         {% endif %}
         {% if settings.MAINTENANCE_MODE %}
@@ -286,24 +286,24 @@
                 <p>NetBox is currently in maintenance mode. Functionality may be limited.</p>
             </div>
         {% 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>
- 		{% 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 }}
             </div>
         {% endif %}
-	</div>
-	<footer class="footer">
-		<div class="container">
+    </div>
+    <footer class="footer">
+        <div class="container">
             <div class="row">
                 <div class="col-xs-4">
                     <p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
@@ -320,8 +320,8 @@
                     </p>
                 </div>
             </div>
-		</div>
-	</footer>
+        </div>
+    </footer>
 <script type="text/javascript">
     var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
 </script>

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

@@ -196,35 +196,6 @@
         {% 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-heading">
                 <strong>Services</strong>
             </div>
             {% if services %}
@@ -250,7 +221,7 @@
             <div class="panel-heading">
                 <strong>Critical Connections</strong>
             </div>
-            <table class="table table-hover panel-body">
+            <table class="table table-hover panel-body component-list">
                 {% for iface in mgmt_interfaces %}
                     {% include 'dcim/inc/interface.html' with icon='wrench' %}
                 {% empty %}
@@ -389,7 +360,7 @@
                         {% endif %}
                     </div>
                 </div>
-                <table class="table table-hover panel-body">
+                <table class="table table-hover panel-body component-list">
                     {% for devicebay in device_bays %}
                         {% include 'dcim/inc/devicebay.html' with selectable=True %}
                     {% empty %}
@@ -430,6 +401,9 @@
                 <div class="panel-heading">
                     <strong>Interfaces</strong>
                     <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 %}
                             <button class="btn btn-default btn-xs toggle">
                                 <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
@@ -442,7 +416,7 @@
                         {% endif %}
                     </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 %}
                         {% include 'dcim/inc/interface.html' with selectable=True %}
                     {% empty %}
@@ -499,7 +473,7 @@
                         {% endif %}
                     </div>
                 </div>
-                <table class="table table-hover panel-body">
+                <table class="table table-hover panel-body component-list">
                     {% for csp in cs_ports %}
                         {% include 'dcim/inc/consoleserverport.html' with selectable=True %}
                     {% empty %}
@@ -551,7 +525,7 @@
                         {% endif %}
                     </div>
                 </div>
-                <table class="table table-hover panel-body">
+                <table class="table table-hover panel-body component-list">
                     {% for po in power_outlets %}
                         {% include 'dcim/inc/poweroutlet.html' with selectable=True %}
                     {% empty %}
@@ -642,6 +616,18 @@ $(".powerport-toggle").click(function() {
 $(".interface-toggle").click(function() {
     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 src="{% static 'js/graphs.js' %}?v{{ settings.VERSION }}"></script>
 <script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></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 %}
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ cp.pk }}" />
@@ -7,7 +7,6 @@
     <td>
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp.name }}
     </td>
-    <td></td>
     {% if cp.cs_port %}
         <td>
             <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>
         </td>
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_consoleport %}
             {% if cp.cs_port %}
                 {% 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 %}
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ csp.pk }}" />
@@ -19,7 +19,7 @@
             <span class="text-muted">Not connected</span>
         </td>
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_consoleserverport %}
             {% if csp.connected_console %}
                 {% 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 %}
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
@@ -19,7 +19,7 @@
             <span class="text-muted">Vacant</span>
         </td>
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_devicebay %}
             {% if devicebay.installed_device %}
                 <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 %}
         <td class="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>
         {% endif %}
     </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>
     {% elif iface.connection %}
         {% with iface.connected_interface as connected_iface %}
@@ -51,7 +50,7 @@
             <span class="text-muted">Not connected</span>
         </td>
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if show_graphs %}
             {% 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">
@@ -59,6 +58,11 @@
                 </button>
             {% 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 not iface.is_virtual %}
                 {% if iface.connection %}
@@ -71,19 +75,19 @@
                             <i class="fa fa-plug" aria-hidden="true"></i>
                         </a>
                     {% 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>
                 {% 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">
                         <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
                     </button>
                     <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>
                 {% else %}
                     <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>
                 {% endif %}
             {% endif %}
@@ -104,3 +108,41 @@
         {% endif %}
     </td>
 </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 %}
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ po.pk }}" />
@@ -19,7 +19,7 @@
             <span class="text-muted">Not connected</span>
         </td>
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_poweroutlet %}
             {% if po.connected_port %}
                 {% 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 %}
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ pp.pk }}" />
@@ -7,7 +7,6 @@
     <td>
         <i class="fa fa-fw fa-bolt"></i> {{ pp.name }}
     </td>
-    <td></td>
     {% if pp.power_outlet %}
         <td>
             <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>
         </td>
     {% endif %}
-    <td class="text-right">
+    <td colspan="2" class="text-right">
         {% if perms.dcim.change_powerport %}
             {% if pp.power_outlet %}
                 {% if pp.connection_status %}

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

@@ -98,14 +98,8 @@
                     <td>
                         {% if ipaddress.interface %}
                             <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 %}
                             <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 %}
                     </td>
                 </tr>

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

@@ -16,40 +16,21 @@
             {% render_field form.vrf %}
             {% render_field form.tenant %}
             {% 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 %}
         </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-heading"><strong>NAT IP (Inside)</strong></div>
         <div class="panel-body">
             <ul class="nav nav-tabs" role="tablist">

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

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

+ 1 - 2
netbox/tenancy/views.py

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

+ 9 - 4
netbox/utilities/forms.py

@@ -398,15 +398,20 @@ class BootstrapMixin(forms.BaseForm):
                 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())
 
 
+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):
 
     def __init__(self, model, *args, **kwargs):

+ 40 - 33
netbox/utilities/views.py

@@ -12,7 +12,9 @@ from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import TemplateSyntaxError
 from django.urls import reverse
+from django.utils.html import escape
 from django.utils.http import is_safe_url
+from django.utils.safestring import mark_safe
 from django.views.generic import View
 
 from extras.forms import CustomFieldForm
@@ -39,6 +41,23 @@ class CustomFieldQueryset:
             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):
     """
     List a series of objects.
@@ -128,21 +147,18 @@ class ObjectListView(View):
         return {}
 
 
-class ObjectEditView(View):
+class ObjectEditView(GetReturnURLMixin, View):
     """
     Create or edit a single object.
 
     model: The model of the object being edited
     form_class: The form used to create or edit the object
-    fields_initial: A set of fields that will be prepopulated in the form from the request parameters
     template_name: The name of the template
     default_return_url: The name of the URL used to display a list of this object type
     """
     model = None
     form_class = None
-    fields_initial = []
     template_name = 'utilities/obj_edit.html'
-    default_return_url = 'home'
 
     def get_object(self, kwargs):
         # 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.
         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):
 
         obj = self.get_object(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)
 
         return render(request, self.template_name, {
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             'form': form,
-            'return_url': self.get_return_url(obj),
+            'return_url': self.get_return_url(request, obj),
         })
 
     def post(self, request, *args, **kwargs):
@@ -194,10 +205,10 @@ class ObjectEditView(View):
             msg = u'Created ' if obj_created else u'Modified '
             msg += self.model._meta.verbose_name
             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:
-                msg = u'{} {}'.format(msg, obj)
-            messages.success(request, msg)
+                msg = u'{} {}'.format(msg, escape(obj))
+            messages.success(request, mark_safe(msg))
             if obj_created:
                 UserAction.objects.log_create(request.user, obj, msg)
             else:
@@ -205,17 +216,22 @@ class ObjectEditView(View):
 
             if '_addanother' in request.POST:
                 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, {
             'obj': obj,
             'obj_type': self.model._meta.verbose_name,
             '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.
 
@@ -225,7 +241,6 @@ class ObjectDeleteView(View):
     """
     model = None
     template_name = 'utilities/obj_delete.html'
-    default_return_url = 'home'
 
     def get_object(self, kwargs):
         # Look up object by slug if one has been provided. Otherwise, use PK.
@@ -234,24 +249,16 @@ class ObjectDeleteView(View):
         else:
             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):
 
         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, {
             'obj': obj,
             'form': form,
             '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):
@@ -270,17 +277,17 @@ class ObjectDeleteView(View):
             messages.success(request, 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)
             else:
-                return redirect(self.get_return_url(obj))
+                return redirect(self.get_return_url(request, obj))
 
         return render(request, self.template_name, {
             'obj': obj,
             'form': form,
             '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),
         })
 
 

+ 1 - 1
requirements.txt

@@ -11,7 +11,7 @@ djangorestframework>=3.6.2
 graphviz>=0.6
 Markdown>=2.6.7
 natsort>=5.0.0
-ncclient==0.5.2
+ncclient==0.5.3
 netaddr==0.7.18
 paramiko>=2.0.0
 Pillow>=4.0.0