Explorar o código

Merge pull request #1278 from digitalocean/develop

Release v2.0.7
Jeremy Stretch %!s(int64=8) %!d(string=hai) anos
pai
achega
88239e0b0d

+ 13 - 11
docs/installation/netbox.md

@@ -1,12 +1,11 @@
 # Installation
 
-**Debian/Ubuntu**
+**Ubuntu**
 
 Python 3:
 
 ```no-highlight
 # apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
-# update-alternatives --install /usr/bin/python python /usr/bin/python3 1
 ```
 
 Python 2:
@@ -15,7 +14,7 @@ Python 2:
 # apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
 ```
 
-**CentOS/RHEL**
+**CentOS**
 
 Python 3:
 
@@ -57,13 +56,13 @@ Create the base directory for the NetBox installation. For this guide, we'll use
 
 If `git` is not already installed, install it:
 
-**Debian/Ubuntu**
+**Ubuntu**
 
 ```no-highlight
 # apt-get install -y git
 ```
 
-**CentOS/RHEL**
+**CentOS**
 
 ```no-highlight
 # yum install -y git
@@ -150,11 +149,14 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
 
 # Run Database Migrations
 
-Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
+!!! warning
+    The examples on the rest of this page call the `python` executable, which will be Python2 on most systems. Replace this with `python3` if you're running NetBox on Python3.
+
+Before NetBox can run, we need to install the database schema. This is done by running `python manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
 
 ```no-highlight
 # cd /opt/netbox/netbox/
-# ./manage.py migrate
+# python manage.py migrate
 Operations to perform:
   Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
 Running migrations:
@@ -172,7 +174,7 @@ If this step results in a PostgreSQL authentication error, ensure that the usern
 NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
 
 ```no-highlight
-# ./manage.py createsuperuser
+# python manage.py createsuperuser
 Username: admin
 Email address: admin@example.com
 Password:
@@ -183,7 +185,7 @@ Superuser created successfully.
 # Collect Static Files
 
 ```no-highlight
-# ./manage.py collectstatic --no-input
+# python manage.py collectstatic --no-input
 
 You have requested to collect static files at the destination
 location as specified in your settings:
@@ -204,7 +206,7 @@ NetBox ships with some initial data to help you get started: RIR definitions, co
     This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
 
 ```no-highlight
-# ./manage.py loaddata initial_data
+# python manage.py loaddata initial_data
 Installed 43 object(s) from 4 fixture(s)
 ```
 
@@ -213,7 +215,7 @@ Installed 43 object(s) from 4 fixture(s)
 At this point, NetBox should be able to run. We can verify this by starting a development instance:
 
 ```no-highlight
-# ./manage.py runserver 0.0.0.0:8000 --insecure
+# python manage.py runserver 0.0.0.0:8000 --insecure
 Performing system checks...
 
 System check identified no issues (0 silenced).

+ 5 - 2
docs/installation/postgresql.md

@@ -1,15 +1,18 @@
 NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).)
 
+!!! note
+    The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 6.9. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
+
 # Installation
 
-**Debian/Ubuntu**
+**Ubuntu**
 
 ```no-highlight
 # apt-get update
 # apt-get install -y postgresql libpq-dev
 ```
 
-**CentOS/RHEL**
+**CentOS**
 
 ```no-highlight
 # yum install -y postgresql postgresql-server postgresql-devel

+ 1 - 1
docs/installation/web-server.md

@@ -3,7 +3,7 @@
 We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
 
 !!! info
-    Only Debian/Ubuntu instructions are provided here, but the installation process for CentOS/RHEL does not differ much. Please consult the documentation for those distributions for details.
+    For the sake of brevity, only Ubuntu 16.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
 
 ```no-highlight
 # apt-get install -y gunicorn supervisor

+ 1 - 1
netbox/circuits/forms.py

@@ -220,7 +220,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
         label='Interface',
         widget=APISelect(
             api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
-            disabled_indicator='is_connected'
+            disabled_indicator='connection'
         )
     )
 

+ 4 - 4
netbox/circuits/urls.py

@@ -10,7 +10,7 @@ urlpatterns = [
 
     # Providers
     url(r'^providers/$', views.ProviderListView.as_view(), name='provider_list'),
-    url(r'^providers/add/$', views.ProviderEditView.as_view(), name='provider_add'),
+    url(r'^providers/add/$', views.ProviderCreateView.as_view(), name='provider_add'),
     url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
     url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
     url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
@@ -20,13 +20,13 @@ urlpatterns = [
 
     # Circuit types
     url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
-    url(r'^circuit-types/add/$', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
+    url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
     url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
     url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
 
     # Circuits
     url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'),
-    url(r'^circuits/add/$', views.CircuitEditView.as_view(), name='circuit_add'),
+    url(r'^circuits/add/$', views.CircuitCreateView.as_view(), name='circuit_add'),
     url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
     url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
     url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
@@ -36,7 +36,7 @@ urlpatterns = [
     url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
 
     # Circuit terminations
-    url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
+    url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
     url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
     url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
 

+ 24 - 8
netbox/circuits/views.py

@@ -49,14 +49,18 @@ class ProviderView(View):
         })
 
 
-class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'circuits.change_provider'
+class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'circuits.add_provider'
     model = Provider
     form_class = forms.ProviderForm
     template_name = 'circuits/provider_edit.html'
     default_return_url = 'circuits:provider_list'
 
 
+class ProviderEditView(ProviderCreateView):
+    permission_required = 'circuits.change_provider'
+
+
 class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'circuits.delete_provider'
     model = Provider
@@ -96,8 +100,8 @@ class CircuitTypeListView(ObjectListView):
     template_name = 'circuits/circuittype_list.html'
 
 
-class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'circuits.change_circuittype'
+class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'circuits.add_circuittype'
     model = CircuitType
     form_class = forms.CircuitTypeForm
 
@@ -105,6 +109,10 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('circuits:circuittype_list')
 
 
+class CircuitTypeEditView(CircuitTypeCreateView):
+    permission_required = 'circuits.change_circuittype'
+
+
 class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuittype'
     cls = CircuitType
@@ -146,14 +154,18 @@ class CircuitView(View):
         })
 
 
-class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'circuits.change_circuit'
+class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'circuits.add_circuit'
     model = Circuit
     form_class = forms.CircuitForm
     template_name = 'circuits/circuit_edit.html'
     default_return_url = 'circuits:circuit_list'
 
 
+class CircuitEditView(CircuitCreateView):
+    permission_required = 'circuits.change_circuit'
+
+
 class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'circuits.delete_circuit'
     model = Circuit
@@ -232,8 +244,8 @@ def circuit_terminations_swap(request, pk):
 # Circuit terminations
 #
 
-class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'circuits.change_circuittermination'
+class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'circuits.add_circuittermination'
     model = CircuitTermination
     form_class = forms.CircuitTerminationForm
     template_name = 'circuits/circuittermination_edit.html'
@@ -247,6 +259,10 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
         return obj.circuit.get_absolute_url()
 
 
+class CircuitTerminationEditView(CircuitTerminationCreateView):
+    permission_required = 'circuits.change_circuittermination'
+
+
 class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'circuits.delete_circuittermination'
     model = CircuitTermination

+ 18 - 5
netbox/dcim/forms.py

@@ -13,8 +13,8 @@ from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
     APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVChoiceField, ExpandableNameField, FilterChoiceField,
-    FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
+    ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField,
+    FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
     FilterTreeNodeMultipleChoiceField,
 )
 from .formfields import MACAddressFormField
@@ -1174,6 +1174,10 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
         }
 
 
+class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
+
+
 #
 # Power ports
 #
@@ -1431,6 +1435,10 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
         }
 
 
+class PowerOutletBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
+
+
 #
 # Interfaces
 #
@@ -1508,6 +1516,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
             self.fields['lag'].choices = []
 
 
+class InterfaceBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
+
+
 #
 # Interface connections
 #
@@ -1594,9 +1606,10 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
         ]
 
         # Mark connected interfaces as disabled
-        self.fields['interface_b'].choices = [
-            (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
-        ]
+        if self.data.get('device_b'):
+            self.fields['interface_b'].choices = [
+                (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
+            ]
 
 
 class InterfaceConnectionCSVForm(forms.ModelForm):

+ 27 - 24
netbox/dcim/urls.py

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
 from django.conf.urls import url
 
 from extras.views import ImageAttachmentEditView
-from ipam.views import ServiceEditView
+from ipam.views import ServiceCreateView
 from secrets.views import secret_add
 from .models import Device, Rack, Site
 from . import views
@@ -14,13 +14,13 @@ urlpatterns = [
 
     # Regions
     url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
-    url(r'^regions/add/$', views.RegionEditView.as_view(), name='region_add'),
+    url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'),
     url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
     url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
 
     # Sites
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
-    url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
+    url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'),
     url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
     url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
@@ -30,13 +30,13 @@ urlpatterns = [
 
     # Rack groups
     url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
-    url(r'^rack-groups/add/$', views.RackGroupEditView.as_view(), name='rackgroup_add'),
+    url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
     url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
     url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
 
     # Rack roles
     url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
-    url(r'^rack-roles/add/$', views.RackRoleEditView.as_view(), name='rackrole_add'),
+    url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'),
     url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
     url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
 
@@ -56,18 +56,18 @@ urlpatterns = [
     url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
     url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
     url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
-    url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
+    url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
     url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
     # Manufacturers
     url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
-    url(r'^manufacturers/add/$', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
+    url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
     url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
     url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
 
     # Device types
     url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
-    url(r'^device-types/add/$', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
+    url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
     url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
     url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
     url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
@@ -75,45 +75,45 @@ urlpatterns = [
     url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
 
     # Console port templates
-    url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateAddView.as_view(), name='devicetype_add_consoleport'),
+    url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
     url(r'^device-types/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
 
     # Console server port templates
-    url(r'^device-types/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateAddView.as_view(), name='devicetype_add_consoleserverport'),
+    url(r'^device-types/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'),
     url(r'^device-types/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
 
     # Power port templates
-    url(r'^device-types/(?P<pk>\d+)/power-ports/add/$', views.PowerPortTemplateAddView.as_view(), name='devicetype_add_powerport'),
+    url(r'^device-types/(?P<pk>\d+)/power-ports/add/$', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'),
     url(r'^device-types/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
 
     # Power outlet templates
-    url(r'^device-types/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletTemplateAddView.as_view(), name='devicetype_add_poweroutlet'),
+    url(r'^device-types/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'),
     url(r'^device-types/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
 
     # Interface templates
-    url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'),
+    url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'),
     url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
     url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
 
     # Device bay templates
-    url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateAddView.as_view(), name='devicetype_add_devicebay'),
+    url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
     url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
 
     # Device roles
     url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
-    url(r'^device-roles/add/$', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
+    url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
     url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
     url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
 
     # Platforms
     url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
-    url(r'^platforms/add/$', views.PlatformEditView.as_view(), name='platform_add'),
+    url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'),
     url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
     url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
 
     # Devices
     url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
-    url(r'^devices/add/$', views.DeviceEditView.as_view(), name='device_add'),
+    url(r'^devices/add/$', views.DeviceCreateView.as_view(), name='device_add'),
     url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
     url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
     url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
@@ -124,12 +124,12 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     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/$', ServiceCreateView.as_view(), name='service_assign'),
     url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
 
     # Console ports
     url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
-    url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortAddView.as_view(), name='consoleport_add'),
+    url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
     url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
     url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
     url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
@@ -138,7 +138,8 @@ urlpatterns = [
 
     # Console server ports
     url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
-    url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortAddView.as_view(), name='consoleserverport_add'),
+    url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
+    url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
     url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
     url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
@@ -147,7 +148,7 @@ urlpatterns = [
 
     # Power ports
     url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
-    url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortAddView.as_view(), name='powerport_add'),
+    url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
     url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
     url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
     url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
@@ -156,7 +157,8 @@ urlpatterns = [
 
     # Power outlets
     url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
-    url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletAddView.as_view(), name='poweroutlet_add'),
+    url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
+    url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
     url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
     url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
@@ -165,8 +167,9 @@ urlpatterns = [
 
     # Interfaces
     url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
-    url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceAddView.as_view(), name='interface_add'),
+    url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
     url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
+    url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
     url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
@@ -175,7 +178,7 @@ urlpatterns = [
 
     # Device bays
     url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
-    url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayAddView.as_view(), name='devicebay_add'),
+    url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
     url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
     url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
     url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),

+ 149 - 36
netbox/dcim/views.py

@@ -1,6 +1,5 @@
 from __future__ import unicode_literals
 from copy import deepcopy
-from difflib import SequenceMatcher
 import re
 from natsort import natsorted
 from operator import attrgetter
@@ -9,7 +8,7 @@ from django.contrib import messages
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.paginator import EmptyPage, PageNotAnInteger
-from django.db.models import Count
+from django.db.models import Count, Q
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
@@ -142,6 +141,44 @@ class ComponentDeleteView(ObjectDeleteView):
         return obj.device.get_absolute_url()
 
 
+class BulkDisconnectView(View):
+    """
+    An extendable view for disconnection console/power/interface components in bulk.
+    """
+    model = None
+    form = None
+    template_name = 'dcim/bulk_disconnect.html'
+
+    def disconnect_objects(self, objects):
+        raise NotImplementedError()
+
+    def post(self, request, pk):
+
+        device = get_object_or_404(Device, pk=pk)
+        selected_objects = []
+
+        if '_confirm' in request.POST:
+            form = self.form(request.POST)
+            if form.is_valid():
+                count = self.disconnect_objects(form.cleaned_data['pk'])
+                messages.success(request, "Disconnected {} {} on {}".format(
+                    count, self.model._meta.verbose_name_plural, device
+                ))
+                return redirect(device.get_absolute_url())
+
+        else:
+            form = self.form(initial={'pk': request.POST.getlist('pk')})
+            selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
+
+        return render(request, self.template_name, {
+            'form': form,
+            'device': device,
+            'obj_type_plural': self.model._meta.verbose_name_plural,
+            'selected_objects': selected_objects,
+            'return_url': device.get_absolute_url(),
+        })
+
+
 #
 # Regions
 #
@@ -152,8 +189,8 @@ class RegionListView(ObjectListView):
     template_name = 'dcim/region_list.html'
 
 
-class RegionEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_region'
+class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_region'
     model = Region
     form_class = forms.RegionForm
 
@@ -161,6 +198,10 @@ class RegionEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:region_list')
 
 
+class RegionEditView(RegionCreateView):
+    permission_required = 'dcim.change_region'
+
+
 class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_region'
     cls = Region
@@ -204,14 +245,18 @@ class SiteView(View):
         })
 
 
-class SiteEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_site'
+class SiteCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_site'
     model = Site
     form_class = forms.SiteForm
     template_name = 'dcim/site_edit.html'
     default_return_url = 'dcim:site_list'
 
 
+class SiteEditView(SiteCreateView):
+    permission_required = 'dcim.change_site'
+
+
 class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_site'
     model = Site
@@ -246,8 +291,8 @@ class RackGroupListView(ObjectListView):
     template_name = 'dcim/rackgroup_list.html'
 
 
-class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_rackgroup'
+class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_rackgroup'
     model = RackGroup
     form_class = forms.RackGroupForm
 
@@ -255,6 +300,10 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:rackgroup_list')
 
 
+class RackGroupEditView(RackGroupCreateView):
+    permission_required = 'dcim.change_rackgroup'
+
+
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackgroup'
     cls = RackGroup
@@ -272,8 +321,8 @@ class RackRoleListView(ObjectListView):
     template_name = 'dcim/rackrole_list.html'
 
 
-class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_rackrole'
+class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_rackrole'
     model = RackRole
     form_class = forms.RackRoleForm
 
@@ -281,6 +330,10 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:rackrole_list')
 
 
+class RackRoleEditView(RackRoleCreateView):
+    permission_required = 'dcim.change_rackrole'
+
+
 class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackrole'
     cls = RackRole
@@ -374,14 +427,18 @@ class RackView(View):
         })
 
 
-class RackEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_rack'
+class RackCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_rack'
     model = Rack
     form_class = forms.RackForm
     template_name = 'dcim/rack_edit.html'
     default_return_url = 'dcim:rack_list'
 
 
+class RackEditView(RackCreateView):
+    permission_required = 'dcim.change_rack'
+
+
 class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_rack'
     model = Rack
@@ -423,8 +480,8 @@ class RackReservationListView(ObjectListView):
     template_name = 'dcim/rackreservation_list.html'
 
 
-class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_rackreservation'
+class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_rackreservation'
     model = RackReservation
     form_class = forms.RackReservationForm
 
@@ -438,6 +495,10 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
         return obj.rack.get_absolute_url()
 
 
+class RackReservationEditView(RackReservationCreateView):
+    permission_required = 'dcim.change_rackreservation'
+
+
 class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_rackreservation'
     model = RackReservation
@@ -462,8 +523,8 @@ class ManufacturerListView(ObjectListView):
     template_name = 'dcim/manufacturer_list.html'
 
 
-class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_manufacturer'
+class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_manufacturer'
     model = Manufacturer
     form_class = forms.ManufacturerForm
 
@@ -471,6 +532,10 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:manufacturer_list')
 
 
+class ManufacturerEditView(ManufacturerCreateView):
+    permission_required = 'dcim.change_manufacturer'
+
+
 class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_manufacturer'
     cls = Manufacturer
@@ -542,14 +607,18 @@ class DeviceTypeView(View):
         })
 
 
-class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_devicetype'
+class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_devicetype'
     model = DeviceType
     form_class = forms.DeviceTypeForm
     template_name = 'dcim/devicetype_edit.html'
     default_return_url = 'dcim:devicetype_list'
 
 
+class DeviceTypeEditView(DeviceTypeCreateView):
+    permission_required = 'dcim.change_devicetype'
+
+
 class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_devicetype'
     model = DeviceType
@@ -576,7 +645,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device type components
 #
 
-class ConsolePortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleporttemplate'
     parent_model = DeviceType
     parent_field = 'device_type'
@@ -593,7 +662,7 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
     parent_cls = DeviceType
 
 
-class ConsoleServerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverporttemplate'
     parent_model = DeviceType
     parent_field = 'device_type'
@@ -608,7 +677,7 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
     parent_cls = DeviceType
 
 
-class PowerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerporttemplate'
     parent_model = DeviceType
     parent_field = 'device_type'
@@ -623,7 +692,7 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     parent_cls = DeviceType
 
 
-class PowerOutletTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlettemplate'
     parent_model = DeviceType
     parent_field = 'device_type'
@@ -638,7 +707,7 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
     parent_cls = DeviceType
 
 
-class InterfaceTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interfacetemplate'
     parent_model = DeviceType
     parent_field = 'device_type'
@@ -661,7 +730,7 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     parent_cls = DeviceType
 
 
-class DeviceBayTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebaytemplate'
     parent_model = DeviceType
     parent_field = 'device_type'
@@ -686,8 +755,8 @@ class DeviceRoleListView(ObjectListView):
     template_name = 'dcim/devicerole_list.html'
 
 
-class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_devicerole'
+class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_devicerole'
     model = DeviceRole
     form_class = forms.DeviceRoleForm
 
@@ -695,6 +764,10 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:devicerole_list')
 
 
+class DeviceRoleEditView(DeviceRoleCreateView):
+    permission_required = 'dcim.change_devicerole'
+
+
 class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicerole'
     cls = DeviceRole
@@ -711,8 +784,8 @@ class PlatformListView(ObjectListView):
     template_name = 'dcim/platform_list.html'
 
 
-class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_platform'
+class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_platform'
     model = Platform
     form_class = forms.PlatformForm
 
@@ -720,6 +793,10 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:platform_list')
 
 
+class PlatformEditView(PlatformCreateView):
+    permission_required = 'dcim.change_platform'
+
+
 class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_platform'
     cls = Platform
@@ -843,14 +920,18 @@ class DeviceLLDPNeighborsView(View):
         })
 
 
-class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'dcim.change_device'
+class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_device'
     model = Device
     form_class = forms.DeviceForm
     template_name = 'dcim/device_edit.html'
     default_return_url = 'dcim:device_list'
 
 
+class DeviceEditView(DeviceCreateView):
+    permission_required = 'dcim.change_device'
+
+
 class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_device'
     model = Device
@@ -904,7 +985,7 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Console ports
 #
 
-class ConsolePortAddView(PermissionRequiredMixin, ComponentCreateView):
+class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleport'
     parent_model = Device
     parent_field = 'device'
@@ -1017,7 +1098,7 @@ class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 # Console server ports
 #
 
-class ConsoleServerPortAddView(PermissionRequiredMixin, ComponentCreateView):
+class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverport'
     parent_model = Device
     parent_field = 'device'
@@ -1116,6 +1197,15 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     model = ConsoleServerPort
 
 
+class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
+    permission_required = 'dcim.change_consoleserverport'
+    model = ConsoleServerPort
+    form = forms.ConsoleServerPortBulkDisconnectForm
+
+    def disconnect_objects(self, cs_ports):
+        return ConsolePort.objects.filter(cs_port__in=cs_ports).update(cs_port=None, connection_status=None)
+
+
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     cls = ConsoleServerPort
@@ -1126,7 +1216,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Power ports
 #
 
-class PowerPortAddView(PermissionRequiredMixin, ComponentCreateView):
+class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerport'
     parent_model = Device
     parent_field = 'device'
@@ -1239,7 +1329,7 @@ class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 # Power outlets
 #
 
-class PowerOutletAddView(PermissionRequiredMixin, ComponentCreateView):
+class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlet'
     parent_model = Device
     parent_field = 'device'
@@ -1338,6 +1428,17 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     model = PowerOutlet
 
 
+class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
+    permission_required = 'dcim.change_poweroutlet'
+    model = PowerOutlet
+    form = forms.PowerOutletBulkDisconnectForm
+
+    def disconnect_objects(self, power_outlets):
+        return PowerPort.objects.filter(power_outlet__in=power_outlets).update(
+            power_outlet=None, connection_status=None
+        )
+
+
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     cls = PowerOutlet
@@ -1348,7 +1449,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Interfaces
 #
 
-class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView):
+class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interface'
     parent_model = Device
     parent_field = 'device'
@@ -1368,6 +1469,18 @@ class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     model = Interface
 
 
+class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
+    permission_required = 'dcim.change_interface'
+    model = Interface
+    form = forms.InterfaceBulkDisconnectForm
+
+    def disconnect_objects(self, interfaces):
+        count, _ = InterfaceConnection.objects.filter(
+            Q(interface_a__in=interfaces) | Q(interface_b__in=interfaces)
+        ).delete()
+        return count
+
+
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interface'
     cls = Interface
@@ -1386,7 +1499,7 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device bays
 #
 
-class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView):
+class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebay'
     parent_model = Device
     parent_field = 'device'

+ 2 - 1
netbox/extras/models.py

@@ -371,7 +371,8 @@ class TopologyMap(models.Model):
         # Add all circuits to the graph
         for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
             peer_termination = termination.get_peer_termination()
-            if peer_termination is not None and peer_termination.interface.device in devices:
+            if (peer_termination is not None and peer_termination.interface is not None and
+                    peer_termination.interface.device in devices):
                 graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
 
         return graph.pipe(format=img_format)

+ 9 - 2
netbox/ipam/forms.py

@@ -1,6 +1,7 @@
 from __future__ import unicode_literals
 
 from django import forms
+from django.core.exceptions import MultipleObjectsReturned
 from django.db.models import Count
 
 from dcim.models import Site, Rack, Device, Interface
@@ -301,6 +302,10 @@ class PrefixCSVForm(forms.ModelForm):
                     ))
                 else:
                     raise forms.ValidationError("Global VLAN {} not found in group {}".format(vlan_vid, vlan_group))
+            except MultipleObjectsReturned:
+                raise forms.ValidationError(
+                    "Multiple VLANs with VID {} found in group {}".format(vlan_vid, vlan_group)
+                )
         elif vlan_vid:
             try:
                 self.instance.vlan = VLAN.objects.get(site=site, group__isnull=True, vid=vlan_vid)
@@ -309,6 +314,8 @@ class PrefixCSVForm(forms.ModelForm):
                     raise forms.ValidationError("VLAN {} not found in site {}".format(vlan_vid, site))
                 else:
                     raise forms.ValidationError("Global VLAN {} not found".format(vlan_vid))
+            except MultipleObjectsReturned:
+                raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid))
 
 
 class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -490,7 +497,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
             initial['interface_site'] = instance.interface.device.site
             initial['interface_rack'] = instance.interface.device.rack
             initial['interface_device'] = instance.interface.device
-        if instance and instance.nat_inside is not None:
+        if instance and instance.nat_inside and instance.nat_inside.device is not None:
             initial['nat_site'] = instance.nat_inside.device.site
             initial['nat_rack'] = instance.nat_inside.device.rack
             initial['nat_device'] = instance.nat_inside.device
@@ -582,7 +589,7 @@ class IPAddressCSVForm(forms.ModelForm):
         }
     )
     status = CSVChoiceField(
-        choices=PREFIX_STATUS_CHOICES,
+        choices=IPADDRESS_STATUS_CHOICES,
         help_text='Operational status'
     )
     device = FlexibleModelChoiceField(

+ 9 - 9
netbox/ipam/urls.py

@@ -10,7 +10,7 @@ urlpatterns = [
 
     # VRFs
     url(r'^vrfs/$', views.VRFListView.as_view(), name='vrf_list'),
-    url(r'^vrfs/add/$', views.VRFEditView.as_view(), name='vrf_add'),
+    url(r'^vrfs/add/$', views.VRFCreateView.as_view(), name='vrf_add'),
     url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
     url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
     url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
@@ -20,13 +20,13 @@ urlpatterns = [
 
     # RIRs
     url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
-    url(r'^rirs/add/$', views.RIREditView.as_view(), name='rir_add'),
+    url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'),
     url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
     url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
 
     # Aggregates
     url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'),
-    url(r'^aggregates/add/$', views.AggregateEditView.as_view(), name='aggregate_add'),
+    url(r'^aggregates/add/$', views.AggregateCreateView.as_view(), name='aggregate_add'),
     url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
     url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
     url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
@@ -36,13 +36,13 @@ urlpatterns = [
 
     # Roles
     url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
-    url(r'^roles/add/$', views.RoleEditView.as_view(), name='role_add'),
+    url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'),
     url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
     url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
 
     # Prefixes
     url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'),
-    url(r'^prefixes/add/$', views.PrefixEditView.as_view(), name='prefix_add'),
+    url(r'^prefixes/add/$', views.PrefixCreateView.as_view(), name='prefix_add'),
     url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
     url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
     url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
@@ -53,8 +53,8 @@ urlpatterns = [
 
     # IP addresses
     url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
-    url(r'^ip-addresses/add/$', views.IPAddressEditView.as_view(), name='ipaddress_add'),
-    url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkAddView.as_view(), name='ipaddress_bulk_add'),
+    url(r'^ip-addresses/add/$', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
+    url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
     url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
     url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
     url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
@@ -64,13 +64,13 @@ urlpatterns = [
 
     # VLAN groups
     url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
-    url(r'^vlan-groups/add/$', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
+    url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
     url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
     url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
 
     # VLANs
     url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
-    url(r'^vlans/add/$', views.VLANEditView.as_view(), name='vlan_add'),
+    url(r'^vlans/add/$', views.VLANCreateView.as_view(), name='vlan_add'),
     url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
     url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
     url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),

+ 56 - 20
netbox/ipam/views.py

@@ -13,7 +13,7 @@ from django.views.generic import View
 from dcim.models import Device
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
-    BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 from . import filters, forms, tables
 from .models import (
@@ -114,14 +114,18 @@ class VRFView(View):
         })
 
 
-class VRFEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_vrf'
+class VRFCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_vrf'
     model = VRF
     form_class = forms.VRFForm
     template_name = 'ipam/vrf_edit.html'
     default_return_url = 'ipam:vrf_list'
 
 
+class VRFEditView(VRFCreateView):
+    permission_required = 'ipam.change_vrf'
+
+
 class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_vrf'
     model = VRF
@@ -239,8 +243,8 @@ class RIRListView(ObjectListView):
         }
 
 
-class RIREditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_rir'
+class RIRCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_rir'
     model = RIR
     form_class = forms.RIRForm
 
@@ -248,6 +252,10 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('ipam:rir_list')
 
 
+class RIREditView(RIRCreateView):
+    permission_required = 'ipam.change_rir'
+
+
 class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_rir'
     cls = RIR
@@ -324,14 +332,18 @@ class AggregateView(View):
         })
 
 
-class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_aggregate'
+class AggregateCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_aggregate'
     model = Aggregate
     form_class = forms.AggregateForm
     template_name = 'ipam/aggregate_edit.html'
     default_return_url = 'ipam:aggregate_list'
 
 
+class AggregateEditView(AggregateCreateView):
+    permission_required = 'ipam.change_aggregate'
+
+
 class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_aggregate'
     model = Aggregate
@@ -371,8 +383,8 @@ class RoleListView(ObjectListView):
     template_name = 'ipam/role_list.html'
 
 
-class RoleEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_role'
+class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_role'
     model = Role
     form_class = forms.RoleForm
 
@@ -380,6 +392,10 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('ipam:role_list')
 
 
+class RoleEditView(RoleCreateView):
+    permission_required = 'ipam.change_role'
+
+
 class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_role'
     cls = Role
@@ -519,14 +535,18 @@ class PrefixIPAddressesView(View):
         })
 
 
-class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_prefix'
+class PrefixCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_prefix'
     model = Prefix
     form_class = forms.PrefixForm
     template_name = 'ipam/prefix_edit.html'
     default_return_url = 'ipam:prefix_list'
 
 
+class PrefixEditView(PrefixCreateView):
+    permission_required = 'ipam.change_prefix'
+
+
 class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_prefix'
     model = Prefix
@@ -612,21 +632,25 @@ class IPAddressView(View):
         })
 
 
-class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_ipaddress'
+class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_ipaddress'
     model = IPAddress
     form_class = forms.IPAddressForm
     template_name = 'ipam/ipaddress_edit.html'
     default_return_url = 'ipam:ipaddress_list'
 
 
+class IPAddressEditView(IPAddressCreateView):
+    permission_required = 'ipam.change_ipaddress'
+
+
 class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_ipaddress'
     model = IPAddress
     default_return_url = 'ipam:ipaddress_list'
 
 
-class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
+class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
     permission_required = 'ipam.add_ipaddress'
     pattern_form = forms.IPAddressPatternForm
     model_form = forms.IPAddressBulkAddForm
@@ -683,8 +707,8 @@ class VLANGroupListView(ObjectListView):
     template_name = 'ipam/vlangroup_list.html'
 
 
-class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_vlangroup'
+class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_vlangroup'
     model = VLANGroup
     form_class = forms.VLANGroupForm
 
@@ -692,6 +716,10 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('ipam:vlangroup_list')
 
 
+class VLANGroupEditView(VLANGroupCreateView):
+    permission_required = 'ipam.change_vlangroup'
+
+
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlangroup'
     cls = VLANGroup
@@ -728,14 +756,18 @@ class VLANView(View):
         })
 
 
-class VLANEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_vlan'
+class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_vlan'
     model = VLAN
     form_class = forms.VLANForm
     template_name = 'ipam/vlan_edit.html'
     default_return_url = 'ipam:vlan_list'
 
 
+class VLANEditView(VLANCreateView):
+    permission_required = 'ipam.change_vlan'
+
+
 class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_vlan'
     model = VLAN
@@ -769,8 +801,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Services
 #
 
-class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_service'
+class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_service'
     model = Service
     form_class = forms.ServiceForm
     template_name = 'ipam/service_edit.html'
@@ -784,6 +816,10 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
         return obj.device.get_absolute_url()
 
 
+class ServiceEditView(ServiceCreateView):
+    permission_required = 'ipam.change_service'
+
+
 class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_service'
     model = Service

+ 1 - 1
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
 
 
-VERSION = '2.0.6'
+VERSION = '2.0.7'
 
 # Import required configuration parameters
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None

+ 12 - 2
netbox/project-static/js/livesearch.js

@@ -1,6 +1,7 @@
 $(document).ready(function() {
     var search_field = $('#id_livesearch');
     var real_field = $('#id_' + search_field.attr('data-field'));
+    var select_fields = $('#select select');
     var search_key = search_field.attr('data-key');
     var label = search_field.attr('data-label');
     if (!label) {
@@ -40,13 +41,22 @@ $(document).ready(function() {
         select: function(event, ui) {
             event.preventDefault();
             search_field.val(ui.item.label);
+            select_fields.val('');
+            select_fields.attr('disabled', 'disabled');
             real_field.empty();
             real_field.append($("<option></option>").attr('value', ui.item.value).text(ui.item.label));
             real_field.change();
-            // If the field has a parent helper, reset the parent to no selection
-            $('select[filter-for="' + real_field.attr('name') + '"]').val('');
+            // Disable parent selection fields
+            // $('select[filter-for="' + real_field.attr('name') + '"]').val('');
         },
         minLength: 4,
         delay: 500
     });
+
+    search_field.change(function() {
+        if (!search_field.val()) {
+            select_fields.removeAttr('disabled');
+            select_fields.val('');
+        }
+    });
 });

+ 1 - 1
netbox/secrets/urls.py

@@ -10,7 +10,7 @@ urlpatterns = [
 
     # Secret roles
     url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'),
-    url(r'^secret-roles/add/$', views.SecretRoleEditView.as_view(), name='secretrole_add'),
+    url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
     url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
     url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
 

+ 6 - 2
netbox/secrets/views.py

@@ -40,8 +40,8 @@ class SecretRoleListView(ObjectListView):
     template_name = 'secrets/secretrole_list.html'
 
 
-class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'secrets.change_secretrole'
+class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'secrets.add_secretrole'
     model = SecretRole
     form_class = forms.SecretRoleForm
 
@@ -49,6 +49,10 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('secrets:secretrole_list')
 
 
+class SecretRoleEditView(SecretRoleCreateView):
+    permission_required = 'secrets.change_secretrole'
+
+
 class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'secrets.delete_secretrole'
     cls = SecretRole

+ 13 - 0
netbox/templates/dcim/bulk_disconnect.html

@@ -0,0 +1,13 @@
+{% extends 'utilities/confirmation_form.html' %}
+{% load helpers %}
+
+{% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %}
+
+{% block message %}
+    <p>Are you sure you want to disconnect all {{ selected_objects|length }} of these {{ obj_type_plural }} on <strong>{{ device }}</strong>?</p>
+    <ul>
+        {% for obj in selected_objects %}
+            <li>{{ obj }}</li>
+        {% endfor %}
+    </ul>
+{% endblock %}

+ 19 - 4
netbox/templates/dcim/device.html

@@ -424,12 +424,17 @@
                     <div class="panel-footer">
                         {% if interfaces and perms.dcim.change_interface %}
                             <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
-                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit selected
+                                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
+                            </button>
+                        {% endif %}
+                        {% if interfaces and perms.dcim.delete_interfaceconnection %}
+                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
                             </button>
                         {% endif %}
                         {% if interfaces and perms.dcim.delete_interface %}
                             <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                         {% endif %}
                         {% if perms.dcim.add_interface %}
@@ -479,9 +484,14 @@
                 </table>
                 {% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
                     <div class="panel-footer">
+                        {% if cs_ports and perms.dcim.change_consoleport %}
+                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
+                            </button>
+                        {% endif %}
                         {% if cs_ports and perms.dcim.delete_consoleserverport %}
                             <button type="submit" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                         {% endif %}
                         {% if perms.dcim.add_consoleserverport %}
@@ -531,9 +541,14 @@
                 </table>
                 {% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
                     <div class="panel-footer">
+                        {% if power_outlets and perms.dcim.change_powerport %}
+                            <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
+                            </button>
+                        {% endif %}
                         {% if power_outlets and perms.dcim.delete_poweroutlet %}
                             <button type="submit" class="btn btn-danger btn-xs">
-                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                         {% endif %}
                         {% if perms.dcim.add_poweroutlet %}

+ 2 - 2
netbox/tenancy/urls.py

@@ -10,13 +10,13 @@ urlpatterns = [
 
     # Tenant groups
     url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
-    url(r'^tenant-groups/add/$', views.TenantGroupEditView.as_view(), name='tenantgroup_add'),
+    url(r'^tenant-groups/add/$', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'),
     url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
     url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
 
     # Tenants
     url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'),
-    url(r'^tenants/add/$', views.TenantEditView.as_view(), name='tenant_add'),
+    url(r'^tenants/add/$', views.TenantCreateView.as_view(), name='tenant_add'),
     url(r'^tenants/import/$', views.TenantBulkImportView.as_view(), name='tenant_import'),
     url(r'^tenants/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
     url(r'^tenants/delete/$', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),

+ 12 - 4
netbox/tenancy/views.py

@@ -26,8 +26,8 @@ class TenantGroupListView(ObjectListView):
     template_name = 'tenancy/tenantgroup_list.html'
 
 
-class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'tenancy.change_tenantgroup'
+class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'tenancy.add_tenantgroup'
     model = TenantGroup
     form_class = forms.TenantGroupForm
 
@@ -35,6 +35,10 @@ class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('tenancy:tenantgroup_list')
 
 
+class TenantGroupEditView(TenantGroupCreateView):
+    permission_required = 'tenancy.change_tenantgroup'
+
+
 class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'tenancy.delete_tenantgroup'
     cls = TenantGroup
@@ -81,14 +85,18 @@ class TenantView(View):
         })
 
 
-class TenantEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'tenancy.change_tenant'
+class TenantCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'tenancy.add_tenant'
     model = Tenant
     form_class = forms.TenantForm
     template_name = 'tenancy/tenant_edit.html'
     default_return_url = 'tenancy:tenant_list'
 
 
+class TenantEditView(TenantCreateView):
+    permission_required = 'tenancy.change_tenant'
+
+
 class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'tenancy.delete_tenant'
     model = Tenant

+ 4 - 1
netbox/utilities/forms.py

@@ -249,7 +249,7 @@ class CSVDataField(forms.CharField):
         reader = csv.reader(value.splitlines())
 
         # Consume and valdiate the first line of CSV data as column headers
-        headers = reader.next()
+        headers = next(reader)
         for f in self.required_fields:
             if f not in headers:
                 raise forms.ValidationError('Required column header "{}" not found.'.format(f))
@@ -472,6 +472,9 @@ class ChainedFieldsMixin(forms.BaseForm):
     def __init__(self, *args, **kwargs):
         super(ChainedFieldsMixin, self).__init__(*args, **kwargs)
 
+        # if self.is_bound:
+        #     assert False, self.data
+
         for field_name, field in self.fields.items():
 
             if isinstance(field, ChainedModelChoiceField):

+ 1 - 1
netbox/utilities/views.py

@@ -290,7 +290,7 @@ class ObjectDeleteView(GetReturnURLMixin, View):
         })
 
 
-class BulkAddView(View):
+class BulkCreateView(View):
     """
     Create new objects in bulk.