Просмотр исходного кода

Merge pull request #1278 from digitalocean/develop

Release v2.0.7
Jeremy Stretch 8 лет назад
Родитель
Сommit
88239e0b0d

+ 13 - 11
docs/installation/netbox.md

@@ -1,12 +1,11 @@
 # Installation
 # Installation
 
 
-**Debian/Ubuntu**
+**Ubuntu**
 
 
 Python 3:
 Python 3:
 
 
 ```no-highlight
 ```no-highlight
 # apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
 # 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:
 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
 # 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:
 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:
 If `git` is not already installed, install it:
 
 
-**Debian/Ubuntu**
+**Ubuntu**
 
 
 ```no-highlight
 ```no-highlight
 # apt-get install -y git
 # apt-get install -y git
 ```
 ```
 
 
-**CentOS/RHEL**
+**CentOS**
 
 
 ```no-highlight
 ```no-highlight
 # yum install -y git
 # 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
 # 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
 ```no-highlight
 # cd /opt/netbox/netbox/
 # cd /opt/netbox/netbox/
-# ./manage.py migrate
+# python manage.py migrate
 Operations to perform:
 Operations to perform:
   Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
   Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
 Running migrations:
 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:
 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
 ```no-highlight
-# ./manage.py createsuperuser
+# python manage.py createsuperuser
 Username: admin
 Username: admin
 Email address: admin@example.com
 Email address: admin@example.com
 Password:
 Password:
@@ -183,7 +185,7 @@ Superuser created successfully.
 # Collect Static Files
 # Collect Static Files
 
 
 ```no-highlight
 ```no-highlight
-# ./manage.py collectstatic --no-input
+# python manage.py collectstatic --no-input
 
 
 You have requested to collect static files at the destination
 You have requested to collect static files at the destination
 location as specified in your settings:
 location as specified in your settings:
@@ -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.
     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
 ```no-highlight
-# ./manage.py loaddata initial_data
+# python manage.py loaddata initial_data
 Installed 43 object(s) from 4 fixture(s)
 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:
 At this point, NetBox should be able to run. We can verify this by starting a development instance:
 
 
 ```no-highlight
 ```no-highlight
-# ./manage.py runserver 0.0.0.0:8000 --insecure
+# python manage.py runserver 0.0.0.0:8000 --insecure
 Performing system checks...
 Performing system checks...
 
 
 System check identified no issues (0 silenced).
 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).)
 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
 # Installation
 
 
-**Debian/Ubuntu**
+**Ubuntu**
 
 
 ```no-highlight
 ```no-highlight
 # apt-get update
 # apt-get update
 # apt-get install -y postgresql libpq-dev
 # apt-get install -y postgresql libpq-dev
 ```
 ```
 
 
-**CentOS/RHEL**
+**CentOS**
 
 
 ```no-highlight
 ```no-highlight
 # yum install -y postgresql postgresql-server postgresql-devel
 # 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.
 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
 !!! 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
 ```no-highlight
 # apt-get install -y gunicorn supervisor
 # apt-get install -y gunicorn supervisor

+ 1 - 1
netbox/circuits/forms.py

@@ -220,7 +220,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
         label='Interface',
         label='Interface',
         widget=APISelect(
         widget=APISelect(
             api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
             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
     # Providers
     url(r'^providers/$', views.ProviderListView.as_view(), name='provider_list'),
     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/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
     url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
     url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
     url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
     url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
@@ -20,13 +20,13 @@ urlpatterns = [
 
 
     # Circuit types
     # Circuit types
     url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
     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/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
     url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
     url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
 
 
     # Circuits
     # Circuits
     url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'),
     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/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
     url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
     url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
     url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
     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'),
     url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
 
 
     # Circuit terminations
     # 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+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
     url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
     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
     model = Provider
     form_class = forms.ProviderForm
     form_class = forms.ProviderForm
     template_name = 'circuits/provider_edit.html'
     template_name = 'circuits/provider_edit.html'
     default_return_url = 'circuits:provider_list'
     default_return_url = 'circuits:provider_list'
 
 
 
 
+class ProviderEditView(ProviderCreateView):
+    permission_required = 'circuits.change_provider'
+
+
 class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'circuits.delete_provider'
     permission_required = 'circuits.delete_provider'
     model = Provider
     model = Provider
@@ -96,8 +100,8 @@ class CircuitTypeListView(ObjectListView):
     template_name = 'circuits/circuittype_list.html'
     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
     model = CircuitType
     form_class = forms.CircuitTypeForm
     form_class = forms.CircuitTypeForm
 
 
@@ -105,6 +109,10 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('circuits:circuittype_list')
         return reverse('circuits:circuittype_list')
 
 
 
 
+class CircuitTypeEditView(CircuitTypeCreateView):
+    permission_required = 'circuits.change_circuittype'
+
+
 class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuittype'
     permission_required = 'circuits.delete_circuittype'
     cls = 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
     model = Circuit
     form_class = forms.CircuitForm
     form_class = forms.CircuitForm
     template_name = 'circuits/circuit_edit.html'
     template_name = 'circuits/circuit_edit.html'
     default_return_url = 'circuits:circuit_list'
     default_return_url = 'circuits:circuit_list'
 
 
 
 
+class CircuitEditView(CircuitCreateView):
+    permission_required = 'circuits.change_circuit'
+
+
 class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'circuits.delete_circuit'
     permission_required = 'circuits.delete_circuit'
     model = Circuit
     model = Circuit
@@ -232,8 +244,8 @@ def circuit_terminations_swap(request, pk):
 # Circuit terminations
 # Circuit terminations
 #
 #
 
 
-class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'circuits.change_circuittermination'
+class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'circuits.add_circuittermination'
     model = CircuitTermination
     model = CircuitTermination
     form_class = forms.CircuitTerminationForm
     form_class = forms.CircuitTerminationForm
     template_name = 'circuits/circuittermination_edit.html'
     template_name = 'circuits/circuittermination_edit.html'
@@ -247,6 +259,10 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
         return obj.circuit.get_absolute_url()
         return obj.circuit.get_absolute_url()
 
 
 
 
+class CircuitTerminationEditView(CircuitTerminationCreateView):
+    permission_required = 'circuits.change_circuittermination'
+
+
 class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'circuits.delete_circuittermination'
     permission_required = 'circuits.delete_circuittermination'
     model = CircuitTermination
     model = CircuitTermination

+ 18 - 5
netbox/dcim/forms.py

@@ -13,8 +13,8 @@ from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     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,
     FilterTreeNodeMultipleChoiceField,
 )
 )
 from .formfields import MACAddressFormField
 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
 # 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
 # Interfaces
 #
 #
@@ -1508,6 +1516,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
             self.fields['lag'].choices = []
             self.fields['lag'].choices = []
 
 
 
 
+class InterfaceBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
+
+
 #
 #
 # Interface connections
 # Interface connections
 #
 #
@@ -1594,9 +1606,10 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
         ]
         ]
 
 
         # Mark connected interfaces as disabled
         # 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):
 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 django.conf.urls import url
 
 
 from extras.views import ImageAttachmentEditView
 from extras.views import ImageAttachmentEditView
-from ipam.views import ServiceEditView
+from ipam.views import ServiceCreateView
 from secrets.views import secret_add
 from secrets.views import secret_add
 from .models import Device, Rack, Site
 from .models import Device, Rack, Site
 from . import views
 from . import views
@@ -14,13 +14,13 @@ urlpatterns = [
 
 
     # Regions
     # Regions
     url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
     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/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
     url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
     url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
 
 
     # Sites
     # Sites
     url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
     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/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
     url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
     url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
     url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
@@ -30,13 +30,13 @@ urlpatterns = [
 
 
     # Rack groups
     # Rack groups
     url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
     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/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
     url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
     url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
 
 
     # Rack roles
     # Rack roles
     url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
     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/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
     url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
     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+)/$', 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+)/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<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}),
     url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
 
     # Manufacturers
     # Manufacturers
     url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
     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/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
     url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
     url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
 
 
     # Device types
     # Device types
     url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
     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/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/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
     url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
     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'),
     url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
 
 
     # Console port templates
     # 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'),
     url(r'^device-types/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
 
 
     # Console server port templates
     # 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'),
     url(r'^device-types/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
 
 
     # Power port templates
     # 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'),
     url(r'^device-types/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
 
 
     # Power outlet templates
     # 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'),
     url(r'^device-types/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
 
 
     # Interface templates
     # 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/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'),
     url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
 
 
     # Device bay templates
     # 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'),
     url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
 
 
     # Device roles
     # Device roles
     url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     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/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
     url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
     url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
 
 
     # Platforms
     # Platforms
     url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
     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/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
     url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
     url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
 
 
     # Devices
     # Devices
     url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
     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/$', views.DeviceBulkImportView.as_view(), name='device_import'),
     url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
     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'),
     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+)/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+)/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<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}),
     url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
 
 
     # Console ports
     # Console ports
     url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
     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'^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+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
     url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
     url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
@@ -138,7 +138,8 @@ urlpatterns = [
 
 
     # Console server ports
     # Console server ports
     url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
     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'^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+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
     url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
     url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
@@ -147,7 +148,7 @@ urlpatterns = [
 
 
     # Power ports
     # Power ports
     url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
     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'^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+)/connect/$', views.powerport_connect, name='powerport_connect'),
     url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
     url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
@@ -156,7 +157,8 @@ urlpatterns = [
 
 
     # Power outlets
     # Power outlets
     url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
     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'^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+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
     url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
     url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
@@ -165,8 +167,9 @@ urlpatterns = [
 
 
     # Interfaces
     # Interfaces
     url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
     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/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+)/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'^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'),
     url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
@@ -175,7 +178,7 @@ urlpatterns = [
 
 
     # Device bays
     # Device bays
     url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
     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'^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+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
     url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
     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 __future__ import unicode_literals
 from copy import deepcopy
 from copy import deepcopy
-from difflib import SequenceMatcher
 import re
 import re
 from natsort import natsorted
 from natsort import natsorted
 from operator import attrgetter
 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.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.paginator import EmptyPage, PageNotAnInteger
 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.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
@@ -142,6 +141,44 @@ class ComponentDeleteView(ObjectDeleteView):
         return obj.device.get_absolute_url()
         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
 # Regions
 #
 #
@@ -152,8 +189,8 @@ class RegionListView(ObjectListView):
     template_name = 'dcim/region_list.html'
     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
     model = Region
     form_class = forms.RegionForm
     form_class = forms.RegionForm
 
 
@@ -161,6 +198,10 @@ class RegionEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:region_list')
         return reverse('dcim:region_list')
 
 
 
 
+class RegionEditView(RegionCreateView):
+    permission_required = 'dcim.change_region'
+
+
 class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_region'
     permission_required = 'dcim.delete_region'
     cls = 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
     model = Site
     form_class = forms.SiteForm
     form_class = forms.SiteForm
     template_name = 'dcim/site_edit.html'
     template_name = 'dcim/site_edit.html'
     default_return_url = 'dcim:site_list'
     default_return_url = 'dcim:site_list'
 
 
 
 
+class SiteEditView(SiteCreateView):
+    permission_required = 'dcim.change_site'
+
+
 class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_site'
     permission_required = 'dcim.delete_site'
     model = Site
     model = Site
@@ -246,8 +291,8 @@ class RackGroupListView(ObjectListView):
     template_name = 'dcim/rackgroup_list.html'
     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
     model = RackGroup
     form_class = forms.RackGroupForm
     form_class = forms.RackGroupForm
 
 
@@ -255,6 +300,10 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:rackgroup_list')
         return reverse('dcim:rackgroup_list')
 
 
 
 
+class RackGroupEditView(RackGroupCreateView):
+    permission_required = 'dcim.change_rackgroup'
+
+
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackgroup'
     permission_required = 'dcim.delete_rackgroup'
     cls = RackGroup
     cls = RackGroup
@@ -272,8 +321,8 @@ class RackRoleListView(ObjectListView):
     template_name = 'dcim/rackrole_list.html'
     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
     model = RackRole
     form_class = forms.RackRoleForm
     form_class = forms.RackRoleForm
 
 
@@ -281,6 +330,10 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:rackrole_list')
         return reverse('dcim:rackrole_list')
 
 
 
 
+class RackRoleEditView(RackRoleCreateView):
+    permission_required = 'dcim.change_rackrole'
+
+
 class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rackrole'
     permission_required = 'dcim.delete_rackrole'
     cls = 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
     model = Rack
     form_class = forms.RackForm
     form_class = forms.RackForm
     template_name = 'dcim/rack_edit.html'
     template_name = 'dcim/rack_edit.html'
     default_return_url = 'dcim:rack_list'
     default_return_url = 'dcim:rack_list'
 
 
 
 
+class RackEditView(RackCreateView):
+    permission_required = 'dcim.change_rack'
+
+
 class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_rack'
     permission_required = 'dcim.delete_rack'
     model = Rack
     model = Rack
@@ -423,8 +480,8 @@ class RackReservationListView(ObjectListView):
     template_name = 'dcim/rackreservation_list.html'
     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
     model = RackReservation
     form_class = forms.RackReservationForm
     form_class = forms.RackReservationForm
 
 
@@ -438,6 +495,10 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
         return obj.rack.get_absolute_url()
         return obj.rack.get_absolute_url()
 
 
 
 
+class RackReservationEditView(RackReservationCreateView):
+    permission_required = 'dcim.change_rackreservation'
+
+
 class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_rackreservation'
     permission_required = 'dcim.delete_rackreservation'
     model = RackReservation
     model = RackReservation
@@ -462,8 +523,8 @@ class ManufacturerListView(ObjectListView):
     template_name = 'dcim/manufacturer_list.html'
     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
     model = Manufacturer
     form_class = forms.ManufacturerForm
     form_class = forms.ManufacturerForm
 
 
@@ -471,6 +532,10 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:manufacturer_list')
         return reverse('dcim:manufacturer_list')
 
 
 
 
+class ManufacturerEditView(ManufacturerCreateView):
+    permission_required = 'dcim.change_manufacturer'
+
+
 class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_manufacturer'
     permission_required = 'dcim.delete_manufacturer'
     cls = 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
     model = DeviceType
     form_class = forms.DeviceTypeForm
     form_class = forms.DeviceTypeForm
     template_name = 'dcim/devicetype_edit.html'
     template_name = 'dcim/devicetype_edit.html'
     default_return_url = 'dcim:devicetype_list'
     default_return_url = 'dcim:devicetype_list'
 
 
 
 
+class DeviceTypeEditView(DeviceTypeCreateView):
+    permission_required = 'dcim.change_devicetype'
+
+
 class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_devicetype'
     permission_required = 'dcim.delete_devicetype'
     model = DeviceType
     model = DeviceType
@@ -576,7 +645,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device type components
 # Device type components
 #
 #
 
 
-class ConsolePortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleporttemplate'
     permission_required = 'dcim.add_consoleporttemplate'
     parent_model = DeviceType
     parent_model = DeviceType
     parent_field = 'device_type'
     parent_field = 'device_type'
@@ -593,7 +662,7 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class ConsoleServerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverporttemplate'
     permission_required = 'dcim.add_consoleserverporttemplate'
     parent_model = DeviceType
     parent_model = DeviceType
     parent_field = 'device_type'
     parent_field = 'device_type'
@@ -608,7 +677,7 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class PowerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerporttemplate'
     permission_required = 'dcim.add_powerporttemplate'
     parent_model = DeviceType
     parent_model = DeviceType
     parent_field = 'device_type'
     parent_field = 'device_type'
@@ -623,7 +692,7 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class PowerOutletTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlettemplate'
     permission_required = 'dcim.add_poweroutlettemplate'
     parent_model = DeviceType
     parent_model = DeviceType
     parent_field = 'device_type'
     parent_field = 'device_type'
@@ -638,7 +707,7 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class InterfaceTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interfacetemplate'
     permission_required = 'dcim.add_interfacetemplate'
     parent_model = DeviceType
     parent_model = DeviceType
     parent_field = 'device_type'
     parent_field = 'device_type'
@@ -661,7 +730,7 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     parent_cls = DeviceType
     parent_cls = DeviceType
 
 
 
 
-class DeviceBayTemplateAddView(PermissionRequiredMixin, ComponentCreateView):
+class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebaytemplate'
     permission_required = 'dcim.add_devicebaytemplate'
     parent_model = DeviceType
     parent_model = DeviceType
     parent_field = 'device_type'
     parent_field = 'device_type'
@@ -686,8 +755,8 @@ class DeviceRoleListView(ObjectListView):
     template_name = 'dcim/devicerole_list.html'
     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
     model = DeviceRole
     form_class = forms.DeviceRoleForm
     form_class = forms.DeviceRoleForm
 
 
@@ -695,6 +764,10 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:devicerole_list')
         return reverse('dcim:devicerole_list')
 
 
 
 
+class DeviceRoleEditView(DeviceRoleCreateView):
+    permission_required = 'dcim.change_devicerole'
+
+
 class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicerole'
     permission_required = 'dcim.delete_devicerole'
     cls = DeviceRole
     cls = DeviceRole
@@ -711,8 +784,8 @@ class PlatformListView(ObjectListView):
     template_name = 'dcim/platform_list.html'
     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
     model = Platform
     form_class = forms.PlatformForm
     form_class = forms.PlatformForm
 
 
@@ -720,6 +793,10 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('dcim:platform_list')
         return reverse('dcim:platform_list')
 
 
 
 
+class PlatformEditView(PlatformCreateView):
+    permission_required = 'dcim.change_platform'
+
+
 class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_platform'
     permission_required = 'dcim.delete_platform'
     cls = 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
     model = Device
     form_class = forms.DeviceForm
     form_class = forms.DeviceForm
     template_name = 'dcim/device_edit.html'
     template_name = 'dcim/device_edit.html'
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
 
 
+class DeviceEditView(DeviceCreateView):
+    permission_required = 'dcim.change_device'
+
+
 class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_device'
     permission_required = 'dcim.delete_device'
     model = Device
     model = Device
@@ -904,7 +985,7 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Console ports
 # Console ports
 #
 #
 
 
-class ConsolePortAddView(PermissionRequiredMixin, ComponentCreateView):
+class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleport'
     permission_required = 'dcim.add_consoleport'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
@@ -1017,7 +1098,7 @@ class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 # Console server ports
 # Console server ports
 #
 #
 
 
-class ConsoleServerPortAddView(PermissionRequiredMixin, ComponentCreateView):
+class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverport'
     permission_required = 'dcim.add_consoleserverport'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
@@ -1116,6 +1197,15 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     model = ConsoleServerPort
     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):
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     permission_required = 'dcim.delete_consoleserverport'
     cls = ConsoleServerPort
     cls = ConsoleServerPort
@@ -1126,7 +1216,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Power ports
 # Power ports
 #
 #
 
 
-class PowerPortAddView(PermissionRequiredMixin, ComponentCreateView):
+class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerport'
     permission_required = 'dcim.add_powerport'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
@@ -1239,7 +1329,7 @@ class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
 # Power outlets
 # Power outlets
 #
 #
 
 
-class PowerOutletAddView(PermissionRequiredMixin, ComponentCreateView):
+class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlet'
     permission_required = 'dcim.add_poweroutlet'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
@@ -1338,6 +1428,17 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     model = PowerOutlet
     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):
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     permission_required = 'dcim.delete_poweroutlet'
     cls = PowerOutlet
     cls = PowerOutlet
@@ -1348,7 +1449,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Interfaces
 # Interfaces
 #
 #
 
 
-class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView):
+class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interface'
     permission_required = 'dcim.add_interface'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
@@ -1368,6 +1469,18 @@ class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     model = Interface
     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):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     cls = Interface
     cls = Interface
@@ -1386,7 +1499,7 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device bays
 # Device bays
 #
 #
 
 
-class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView):
+class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebay'
     permission_required = 'dcim.add_devicebay'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'

+ 2 - 1
netbox/extras/models.py

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

+ 9 - 2
netbox/ipam/forms.py

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

+ 9 - 9
netbox/ipam/urls.py

@@ -10,7 +10,7 @@ urlpatterns = [
 
 
     # VRFs
     # VRFs
     url(r'^vrfs/$', views.VRFListView.as_view(), name='vrf_list'),
     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/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
     url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
     url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
     url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
     url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
@@ -20,13 +20,13 @@ urlpatterns = [
 
 
     # RIRs
     # RIRs
     url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
     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/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
     url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
     url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
 
 
     # Aggregates
     # Aggregates
     url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'),
     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/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
     url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
     url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
     url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
     url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
@@ -36,13 +36,13 @@ urlpatterns = [
 
 
     # Roles
     # Roles
     url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
     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/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
     url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
     url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
 
 
     # Prefixes
     # Prefixes
     url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'),
     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/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
     url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
     url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
     url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
     url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
@@ -53,8 +53,8 @@ urlpatterns = [
 
 
     # IP addresses
     # IP addresses
     url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
     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/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/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
     url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
     url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
@@ -64,13 +64,13 @@ urlpatterns = [
 
 
     # VLAN groups
     # VLAN groups
     url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
     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/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
     url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
     url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
 
 
     # VLANs
     # VLANs
     url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
     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/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
     url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
     url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
     url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
     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 dcim.models import Device
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
 from utilities.views import (
-    BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
 from . import filters, forms, tables
 from . import filters, forms, tables
 from .models import (
 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
     model = VRF
     form_class = forms.VRFForm
     form_class = forms.VRFForm
     template_name = 'ipam/vrf_edit.html'
     template_name = 'ipam/vrf_edit.html'
     default_return_url = 'ipam:vrf_list'
     default_return_url = 'ipam:vrf_list'
 
 
 
 
+class VRFEditView(VRFCreateView):
+    permission_required = 'ipam.change_vrf'
+
+
 class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_vrf'
     permission_required = 'ipam.delete_vrf'
     model = 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
     model = RIR
     form_class = forms.RIRForm
     form_class = forms.RIRForm
 
 
@@ -248,6 +252,10 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('ipam:rir_list')
         return reverse('ipam:rir_list')
 
 
 
 
+class RIREditView(RIRCreateView):
+    permission_required = 'ipam.change_rir'
+
+
 class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_rir'
     permission_required = 'ipam.delete_rir'
     cls = 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
     model = Aggregate
     form_class = forms.AggregateForm
     form_class = forms.AggregateForm
     template_name = 'ipam/aggregate_edit.html'
     template_name = 'ipam/aggregate_edit.html'
     default_return_url = 'ipam:aggregate_list'
     default_return_url = 'ipam:aggregate_list'
 
 
 
 
+class AggregateEditView(AggregateCreateView):
+    permission_required = 'ipam.change_aggregate'
+
+
 class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_aggregate'
     permission_required = 'ipam.delete_aggregate'
     model = Aggregate
     model = Aggregate
@@ -371,8 +383,8 @@ class RoleListView(ObjectListView):
     template_name = 'ipam/role_list.html'
     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
     model = Role
     form_class = forms.RoleForm
     form_class = forms.RoleForm
 
 
@@ -380,6 +392,10 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('ipam:role_list')
         return reverse('ipam:role_list')
 
 
 
 
+class RoleEditView(RoleCreateView):
+    permission_required = 'ipam.change_role'
+
+
 class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_role'
     permission_required = 'ipam.delete_role'
     cls = 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
     model = Prefix
     form_class = forms.PrefixForm
     form_class = forms.PrefixForm
     template_name = 'ipam/prefix_edit.html'
     template_name = 'ipam/prefix_edit.html'
     default_return_url = 'ipam:prefix_list'
     default_return_url = 'ipam:prefix_list'
 
 
 
 
+class PrefixEditView(PrefixCreateView):
+    permission_required = 'ipam.change_prefix'
+
+
 class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_prefix'
     permission_required = 'ipam.delete_prefix'
     model = 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
     model = IPAddress
     form_class = forms.IPAddressForm
     form_class = forms.IPAddressForm
     template_name = 'ipam/ipaddress_edit.html'
     template_name = 'ipam/ipaddress_edit.html'
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'
 
 
 
 
+class IPAddressEditView(IPAddressCreateView):
+    permission_required = 'ipam.change_ipaddress'
+
+
 class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_ipaddress'
     permission_required = 'ipam.delete_ipaddress'
     model = IPAddress
     model = IPAddress
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'
 
 
 
 
-class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
+class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
     permission_required = 'ipam.add_ipaddress'
     permission_required = 'ipam.add_ipaddress'
     pattern_form = forms.IPAddressPatternForm
     pattern_form = forms.IPAddressPatternForm
     model_form = forms.IPAddressBulkAddForm
     model_form = forms.IPAddressBulkAddForm
@@ -683,8 +707,8 @@ class VLANGroupListView(ObjectListView):
     template_name = 'ipam/vlangroup_list.html'
     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
     model = VLANGroup
     form_class = forms.VLANGroupForm
     form_class = forms.VLANGroupForm
 
 
@@ -692,6 +716,10 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('ipam:vlangroup_list')
         return reverse('ipam:vlangroup_list')
 
 
 
 
+class VLANGroupEditView(VLANGroupCreateView):
+    permission_required = 'ipam.change_vlangroup'
+
+
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_vlangroup'
     permission_required = 'ipam.delete_vlangroup'
     cls = 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
     model = VLAN
     form_class = forms.VLANForm
     form_class = forms.VLANForm
     template_name = 'ipam/vlan_edit.html'
     template_name = 'ipam/vlan_edit.html'
     default_return_url = 'ipam:vlan_list'
     default_return_url = 'ipam:vlan_list'
 
 
 
 
+class VLANEditView(VLANCreateView):
+    permission_required = 'ipam.change_vlan'
+
+
 class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_vlan'
     permission_required = 'ipam.delete_vlan'
     model = VLAN
     model = VLAN
@@ -769,8 +801,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Services
 # Services
 #
 #
 
 
-class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'ipam.change_service'
+class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.add_service'
     model = Service
     model = Service
     form_class = forms.ServiceForm
     form_class = forms.ServiceForm
     template_name = 'ipam/service_edit.html'
     template_name = 'ipam/service_edit.html'
@@ -784,6 +816,10 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
         return obj.device.get_absolute_url()
         return obj.device.get_absolute_url()
 
 
 
 
+class ServiceEditView(ServiceCreateView):
+    permission_required = 'ipam.change_service'
+
+
 class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'ipam.delete_service'
     permission_required = 'ipam.delete_service'
     model = 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
 # Import required configuration parameters
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
 ALLOWED_HOSTS = DATABASE = SECRET_KEY = None

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

@@ -1,6 +1,7 @@
 $(document).ready(function() {
 $(document).ready(function() {
     var search_field = $('#id_livesearch');
     var search_field = $('#id_livesearch');
     var real_field = $('#id_' + search_field.attr('data-field'));
     var real_field = $('#id_' + search_field.attr('data-field'));
+    var select_fields = $('#select select');
     var search_key = search_field.attr('data-key');
     var search_key = search_field.attr('data-key');
     var label = search_field.attr('data-label');
     var label = search_field.attr('data-label');
     if (!label) {
     if (!label) {
@@ -40,13 +41,22 @@ $(document).ready(function() {
         select: function(event, ui) {
         select: function(event, ui) {
             event.preventDefault();
             event.preventDefault();
             search_field.val(ui.item.label);
             search_field.val(ui.item.label);
+            select_fields.val('');
+            select_fields.attr('disabled', 'disabled');
             real_field.empty();
             real_field.empty();
             real_field.append($("<option></option>").attr('value', ui.item.value).text(ui.item.label));
             real_field.append($("<option></option>").attr('value', ui.item.value).text(ui.item.label));
             real_field.change();
             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,
         minLength: 4,
         delay: 500
         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
     # Secret roles
     url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'),
     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/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
     url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
     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'
     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
     model = SecretRole
     form_class = forms.SecretRoleForm
     form_class = forms.SecretRoleForm
 
 
@@ -49,6 +49,10 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('secrets:secretrole_list')
         return reverse('secrets:secretrole_list')
 
 
 
 
+class SecretRoleEditView(SecretRoleCreateView):
+    permission_required = 'secrets.change_secretrole'
+
+
 class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'secrets.delete_secretrole'
     permission_required = 'secrets.delete_secretrole'
     cls = 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">
                     <div class="panel-footer">
                         {% if interfaces and perms.dcim.change_interface %}
                         {% 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">
                             <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>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if interfaces and perms.dcim.delete_interface %}
                         {% 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">
                             <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>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_interface %}
                         {% if perms.dcim.add_interface %}
@@ -479,9 +484,14 @@
                 </table>
                 </table>
                 {% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
                 {% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
                     <div class="panel-footer">
                     <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 %}
                         {% if cs_ports and perms.dcim.delete_consoleserverport %}
                             <button type="submit" class="btn btn-danger btn-xs">
                             <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>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_consoleserverport %}
                         {% if perms.dcim.add_consoleserverport %}
@@ -531,9 +541,14 @@
                 </table>
                 </table>
                 {% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
                 {% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
                     <div class="panel-footer">
                     <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 %}
                         {% if power_outlets and perms.dcim.delete_poweroutlet %}
                             <button type="submit" class="btn btn-danger btn-xs">
                             <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>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_poweroutlet %}
                         {% if perms.dcim.add_poweroutlet %}

+ 2 - 2
netbox/tenancy/urls.py

@@ -10,13 +10,13 @@ urlpatterns = [
 
 
     # Tenant groups
     # Tenant groups
     url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
     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/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
     url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
     url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
 
 
     # Tenants
     # Tenants
     url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'),
     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/import/$', views.TenantBulkImportView.as_view(), name='tenant_import'),
     url(r'^tenants/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
     url(r'^tenants/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
     url(r'^tenants/delete/$', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),
     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'
     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
     model = TenantGroup
     form_class = forms.TenantGroupForm
     form_class = forms.TenantGroupForm
 
 
@@ -35,6 +35,10 @@ class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
         return reverse('tenancy:tenantgroup_list')
         return reverse('tenancy:tenantgroup_list')
 
 
 
 
+class TenantGroupEditView(TenantGroupCreateView):
+    permission_required = 'tenancy.change_tenantgroup'
+
+
 class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'tenancy.delete_tenantgroup'
     permission_required = 'tenancy.delete_tenantgroup'
     cls = 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
     model = Tenant
     form_class = forms.TenantForm
     form_class = forms.TenantForm
     template_name = 'tenancy/tenant_edit.html'
     template_name = 'tenancy/tenant_edit.html'
     default_return_url = 'tenancy:tenant_list'
     default_return_url = 'tenancy:tenant_list'
 
 
 
 
+class TenantEditView(TenantCreateView):
+    permission_required = 'tenancy.change_tenant'
+
+
 class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'tenancy.delete_tenant'
     permission_required = 'tenancy.delete_tenant'
     model = Tenant
     model = Tenant

+ 4 - 1
netbox/utilities/forms.py

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