Jeremy Stretch 6 лет назад
Родитель
Сommit
3bb3b85fa2

+ 27 - 0
CHANGELOG.md

@@ -213,6 +213,33 @@ functionality provided by the front end UI.
 
 
 ---
 ---
 
 
+2.5.13 (2019-05-31)
+
+## Enhancements
+
+* [#2813](https://github.com/digitalocean/netbox/issues/2813) - Add tenant group filters
+* [#3085](https://github.com/digitalocean/netbox/issues/3085) - Catch all exceptions during export template rendering
+* [#3138](https://github.com/digitalocean/netbox/issues/3138) - Add 2.5GE and 5GE interface form factors
+* [#3151](https://github.com/digitalocean/netbox/issues/3151) - Add inventory item count to manufacturers list
+* [#3156](https://github.com/digitalocean/netbox/issues/3156) - Add site link to rack reservations overview
+* [#3183](https://github.com/digitalocean/netbox/issues/3183) - Enable bulk deletion of sites
+* [#3185](https://github.com/digitalocean/netbox/issues/3185) - Improve performance for custom field access within templates
+* [#3186](https://github.com/digitalocean/netbox/issues/3186) - Add interface name filter for IP addresses
+
+## Bug Fixes
+
+* [#3031](https://github.com/digitalocean/netbox/issues/3031) - Fixed form field population of tags with spaces
+* [#3132](https://github.com/digitalocean/netbox/issues/3132) - Circuit termination missing from available cable termination types
+* [#3150](https://github.com/digitalocean/netbox/issues/3150) - Fix formatting of cable length during cable trace
+* [#3184](https://github.com/digitalocean/netbox/issues/3184) - Correctly display color block for white cables
+* [#3190](https://github.com/digitalocean/netbox/issues/3190) - Fix custom field rendering for Jinja2 export templates
+* [#3211](https://github.com/digitalocean/netbox/issues/3211) - Fix error handling when attempting to delete a protected object via API
+* [#3223](https://github.com/digitalocean/netbox/issues/3223) - Fix filtering devices by "has power outlets"
+* [#3227](https://github.com/digitalocean/netbox/issues/3227) - Fix exception when deleting a circuit with a termination(s)
+* [#3228](https://github.com/digitalocean/netbox/issues/3228) - Fixed login link retaining query parameters
+
+---
+
 2.5.12 (2019-05-01)
 2.5.12 (2019-05-01)
 
 
 ## Bug Fixes
 ## Bug Fixes

+ 2 - 12
netbox/circuits/filters.py

@@ -3,7 +3,7 @@ from django.db.models import Q
 
 
 from dcim.models import Site
 from dcim.models import Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
-from tenancy.models import Tenant
+from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from .constants import CIRCUIT_STATUS_CHOICES
 from .constants import CIRCUIT_STATUS_CHOICES
 from .models import Provider, Circuit, CircuitTermination, CircuitType
 from .models import Provider, Circuit, CircuitTermination, CircuitType
@@ -54,7 +54,7 @@ class CircuitTypeFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class CircuitFilter(CustomFieldFilterSet):
+class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -87,16 +87,6 @@ class CircuitFilter(CustomFieldFilterSet):
         choices=CIRCUIT_STATUS_CHOICES,
         choices=CIRCUIT_STATUS_CHOICES,
         null_value=None
         null_value=None
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='terminations__site',
         field_name='terminations__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),

+ 3 - 11
netbox/circuits/forms.py

@@ -4,6 +4,7 @@ from taggit.forms import TagField
 from dcim.models import Site
 from dcim.models import Site
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
+from tenancy.forms import TenancyFilterForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
     APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
@@ -265,8 +266,9 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
         ]
         ]
 
 
 
 
-class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Circuit
     model = Circuit
+    field_order = ['q', 'type', 'provider', 'status', 'site', 'tenant_group', 'tenant', 'commit_rate']
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
@@ -292,16 +294,6 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/tenancy/tenants/",
-            value_field="slug",
-            null_option=True,
-        )
-    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',

+ 6 - 1
netbox/circuits/models.py

@@ -274,11 +274,16 @@ class CircuitTermination(CableTermination):
         """
         """
         Reference the parent circuit when recording the change.
         Reference the parent circuit when recording the change.
         """
         """
+        try:
+            related_object = self.circuit
+        except Circuit.DoesNotExist:
+            # Parent circuit has been deleted
+            related_object = None
         ObjectChange(
         ObjectChange(
             user=user,
             user=user,
             request_id=request_id,
             request_id=request_id,
             changed_object=self,
             changed_object=self,
-            related_object=self.circuit,
+            related_object=related_object,
             action=action,
             action=action,
             object_data=serialize_object(self)
             object_data=serialize_object(self)
         ).save()
         ).save()

+ 32 - 31
netbox/circuits/urls.py

@@ -1,4 +1,4 @@
-from django.conf.urls import url
+from django.urls import path
 
 
 from dcim.views import CableCreateView, CableTraceView
 from dcim.views import CableCreateView, CableTraceView
 from extras.views import ObjectChangeLogView
 from extras.views import ObjectChangeLogView
@@ -9,41 +9,42 @@ app_name = 'circuits'
 urlpatterns = [
 urlpatterns = [
 
 
     # Providers
     # Providers
-    url(r'^providers/$', views.ProviderListView.as_view(), name='provider_list'),
-    url(r'^providers/add/$', views.ProviderCreateView.as_view(), name='provider_add'),
-    url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
-    url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
-    url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
-    url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
-    url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
-    url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
-    url(r'^providers/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
+    path(r'providers/', views.ProviderListView.as_view(), name='provider_list'),
+    path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
+    path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
+    path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
+    path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
+    path(r'providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
+    path(r'providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
+    path(r'providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
+    path(r'providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
 
 
     # Circuit types
     # Circuit types
-    url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
-    url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
-    url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
-    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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
+    path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
+    path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
+    path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
+    path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
+    path(r'circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
+    path(r'circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
 
 
     # Circuits
     # Circuits
-    url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'),
-    url(r'^circuits/add/$', views.CircuitCreateView.as_view(), name='circuit_add'),
-    url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
-    url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
-    url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
-    url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'),
-    url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
-    url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
-    url(r'^circuits/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
-    url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
+    path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'),
+    path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
+    path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
+    path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
+    path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
+    path(r'circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
+    path(r'circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
+    path(r'circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
+    path(r'circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
+    path(r'circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
 
 
     # Circuit terminations
     # Circuit terminations
-    url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
-    url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
-    url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
-    url(r'^circuit-terminations/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
-    url(r'^circuit-terminations/(?P<pk>\d+)/trace/$', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
+
+    path(r'circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
+    path(r'circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
+    path(r'circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
+    path(r'circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
+    path(r'circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
 
 
 ]
 ]

+ 5 - 1
netbox/dcim/constants.py

@@ -75,6 +75,8 @@ IFACE_TYPE_100ME_FIXED = 800
 IFACE_TYPE_1GE_FIXED = 1000
 IFACE_TYPE_1GE_FIXED = 1000
 IFACE_TYPE_1GE_GBIC = 1050
 IFACE_TYPE_1GE_GBIC = 1050
 IFACE_TYPE_1GE_SFP = 1100
 IFACE_TYPE_1GE_SFP = 1100
+IFACE_TYPE_2GE_FIXED = 1120
+IFACE_TYPE_5GE_FIXED = 1130
 IFACE_TYPE_10GE_FIXED = 1150
 IFACE_TYPE_10GE_FIXED = 1150
 IFACE_TYPE_10GE_CX4 = 1170
 IFACE_TYPE_10GE_CX4 = 1170
 IFACE_TYPE_10GE_SFP_PLUS = 1200
 IFACE_TYPE_10GE_SFP_PLUS = 1200
@@ -150,6 +152,8 @@ IFACE_TYPE_CHOICES = [
         [
         [
             [IFACE_TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'],
             [IFACE_TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'],
             [IFACE_TYPE_1GE_FIXED, '1000BASE-T (1GE)'],
             [IFACE_TYPE_1GE_FIXED, '1000BASE-T (1GE)'],
+            [IFACE_TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'],
+            [IFACE_TYPE_5GE_FIXED, '5GBASE-T (5GE)'],
             [IFACE_TYPE_10GE_FIXED, '10GBASE-T (10GE)'],
             [IFACE_TYPE_10GE_FIXED, '10GBASE-T (10GE)'],
             [IFACE_TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'],
             [IFACE_TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'],
         ]
         ]
@@ -360,7 +364,7 @@ CONNECTION_STATUS_CHOICES = [
 
 
 # Cable endpoint types
 # Cable endpoint types
 CABLE_TERMINATION_TYPES = [
 CABLE_TERMINATION_TYPES = [
-    'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport',
+    'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination',
 ]
 ]
 
 
 # Cable types
 # Cable types

+ 6 - 45
netbox/dcim/filters.py

@@ -6,6 +6,7 @@ from netaddr import EUI
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
+from tenancy.filtersets import TenancyFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.constants import COLOR_CHOICES
 from utilities.constants import COLOR_CHOICES
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
@@ -37,7 +38,7 @@ class RegionFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class SiteFilter(CustomFieldFilterSet):
+class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -61,16 +62,6 @@ class SiteFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
@@ -125,7 +116,7 @@ class RackRoleFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug', 'color']
         fields = ['id', 'name', 'slug', 'color']
 
 
 
 
-class RackFilter(CustomFieldFilterSet):
+class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -154,16 +145,6 @@ class RackFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Group',
         label='Group',
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=RACK_STATUS_CHOICES,
         choices=RACK_STATUS_CHOICES,
         null_value=None
         null_value=None
@@ -199,7 +180,7 @@ class RackFilter(CustomFieldFilterSet):
         )
         )
 
 
 
 
-class RackReservationFilter(django_filters.FilterSet):
+class RackReservationFilter(TenancyFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -234,16 +215,6 @@ class RackReservationFilter(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Group',
         label='Group',
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     user_id = django_filters.ModelMultipleChoiceFilter(
     user_id = django_filters.ModelMultipleChoiceFilter(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
         label='User (ID)',
         label='User (ID)',
@@ -449,7 +420,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug', 'napalm_driver']
         fields = ['id', 'name', 'slug', 'napalm_driver']
 
 
 
 
-class DeviceFilter(CustomFieldFilterSet):
+class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -484,16 +455,6 @@ class DeviceFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     platform_id = django_filters.ModelMultipleChoiceFilter(
     platform_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         label='Platform (ID)',
         label='Platform (ID)',
@@ -639,7 +600,7 @@ class DeviceFilter(CustomFieldFilterSet):
         return queryset.exclude(powerports__isnull=value)
         return queryset.exclude(powerports__isnull=value)
 
 
     def _power_outlets(self, queryset, name, value):
     def _power_outlets(self, queryset, name, value):
-        return queryset.exclude(poweroutlets_isnull=value)
+        return queryset.exclude(poweroutlets__isnull=value)
 
 
     def _interfaces(self, queryset, name, value):
     def _interfaces(self, queryset, name, value):
         return queryset.exclude(interfaces__isnull=value)
         return queryset.exclude(interfaces__isnull=value)

+ 58 - 68
netbox/dcim/forms.py

@@ -14,7 +14,8 @@ from circuits.models import Circuit, Provider
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from ipam.models import IPAddress, VLAN, VLANGroup
 from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
-from tenancy.models import Tenant
+from tenancy.forms import TenancyFilterForm
+from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
     BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm,
     BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm,
@@ -256,8 +257,9 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         ]
         ]
 
 
 
 
-class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Site
     model = Site
+    field_order = ['q', 'status', 'region', 'tenant_group', 'tenant']
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
@@ -276,16 +278,6 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/tenancy/tenants/",
-            value_field="slug",
-            null_option=True,
-        )
-    )
 
 
 
 
 #
 #
@@ -596,8 +588,9 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         ]
         ]
 
 
 
 
-class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Rack
     model = Rack
+    field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
@@ -619,16 +612,6 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/tenancy/tenants/",
-            value_field="slug",
-            null_option=True,
-        )
-    )
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         choices=RACK_STATUS_CHOICES,
         choices=RACK_STATUS_CHOICES,
         required=False,
         required=False,
@@ -689,40 +672,6 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
         return unit_choices
         return unit_choices
 
 
 
 
-class RackReservationFilterForm(BootstrapMixin, forms.Form):
-    q = forms.CharField(
-        required=False,
-        label='Search'
-    )
-    site = FilterChoiceField(
-        queryset=Site.objects.all(),
-        to_field_name='slug',
-        widget=APISelectMultiple(
-            api_url="/api/dcim/sites/",
-            value_field="slug",
-        )
-    )
-    group_id = FilterChoiceField(
-        queryset=RackGroup.objects.select_related('site'),
-        label='Rack group',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/dcim/rack-groups/",
-            null_option=True,
-        )
-    )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/tenancy/tenants/",
-            value_field="slug",
-            null_option=True,
-        )
-    )
-
-
 class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
 class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=RackReservation.objects.all(),
         queryset=RackReservation.objects.all(),
@@ -751,6 +700,31 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
         nullable_fields = []
         nullable_fields = []
 
 
 
 
+class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
+    field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant']
+    q = forms.CharField(
+        required=False,
+        label='Search'
+    )
+    site = FilterChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+        )
+    )
+    group_id = FilterChoiceField(
+        queryset=RackGroup.objects.select_related('site'),
+        label='Rack group',
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/rack-groups/",
+            null_option=True,
+        )
+    )
+
+
 #
 #
 # Manufacturers
 # Manufacturers
 #
 #
@@ -1656,8 +1630,12 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         ]
         ]
 
 
 
 
-class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Device
     model = Device
+    field_order = [
+        'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
+        'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip',
+    ]
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
@@ -1715,16 +1693,6 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/tenancy/tenants/",
-            value_field="slug",
-            null_option=True,
-        )
-    )
     manufacturer_id = FilterChoiceField(
     manufacturer_id = FilterChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         label='Manufacturer',
         label='Manufacturer',
@@ -3368,11 +3336,33 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+        )
+    )
+    tenant_group = FilterChoiceField(
+        queryset=TenantGroup.objects.all(),
+        to_field_name='slug',
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenant-groups/",
+            value_field="slug",
+            null_option=True,
+            filter_for={
+                'tenant': 'group'
+            }
+        )
     )
     )
     tenant = FilterChoiceField(
     tenant = FilterChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --',
         null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenants/",
+            value_field="slug",
+            null_option=True,
+        )
     )
     )
 
 
 
 

+ 24 - 9
netbox/dcim/tables.py

@@ -314,7 +314,12 @@ class RackDetailTable(RackTable):
 
 
 class RackReservationTable(BaseTable):
 class RackReservationTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        accessor=Accessor('rack.site'),
+        args=[Accessor('rack.site.slug')],
+    )
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
     rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
     unit_list = tables.Column(orderable=False, verbose_name='Units')
     unit_list = tables.Column(orderable=False, verbose_name='Units')
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
@@ -323,7 +328,7 @@ class RackReservationTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = RackReservation
         model = RackReservation
-        fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
+        fields = ('pk', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
 
 
 
 
 #
 #
@@ -332,16 +337,26 @@ class RackReservationTable(BaseTable):
 
 
 class ManufacturerTable(BaseTable):
 class ManufacturerTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    name = tables.LinkColumn(verbose_name='Name')
-    devicetype_count = tables.Column(verbose_name='Device Types')
-    platform_count = tables.Column(verbose_name='Platforms')
-    slug = tables.Column(verbose_name='Slug')
-    actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right noprint'}},
-                                    verbose_name='')
+    name = tables.LinkColumn()
+    devicetype_count = tables.Column(
+        verbose_name='Device Types'
+    )
+    inventoryitem_count = tables.Column(
+        verbose_name='Inventory Items'
+    )
+    platform_count = tables.Column(
+        verbose_name='Platforms'
+    )
+    slug = tables.Column()
+    actions = tables.TemplateColumn(
+        template_code=MANUFACTURER_ACTIONS,
+        attrs={'td': {'class': 'text-right noprint'}},
+        verbose_name=''
+    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Manufacturer
         model = Manufacturer
-        fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions')
+        fields = ('pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'slug', 'actions')
 
 
 
 
 #
 #

+ 226 - 225
netbox/dcim/urls.py

@@ -1,4 +1,4 @@
-from django.conf.urls import url
+from django.urls import path
 
 
 from extras.views import ObjectChangeLogView, ImageAttachmentEditView
 from extras.views import ObjectChangeLogView, ImageAttachmentEditView
 from ipam.views import ServiceCreateView
 from ipam.views import ServiceCreateView
@@ -14,293 +14,294 @@ app_name = 'dcim'
 urlpatterns = [
 urlpatterns = [
 
 
     # Regions
     # Regions
-    url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
-    url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'),
-    url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'),
-    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+)/changelog/$', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
+    path(r'regions/', views.RegionListView.as_view(), name='region_list'),
+    path(r'regions/add/', views.RegionCreateView.as_view(), name='region_add'),
+    path(r'regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
+    path(r'regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
+    path(r'regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
+    path(r'regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
 
 
     # Sites
     # Sites
-    url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
-    url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'),
-    url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
-    url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
-    url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
-    url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
-    url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
-    url(r'^sites/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
-    url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
+    path(r'sites/', views.SiteListView.as_view(), name='site_list'),
+    path(r'sites/add/', views.SiteCreateView.as_view(), name='site_add'),
+    path(r'sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
+    path(r'sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
+    path(r'sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
+    path(r'sites/<slug:slug>/', views.SiteView.as_view(), name='site'),
+    path(r'sites/<slug:slug>/edit/', views.SiteEditView.as_view(), name='site_edit'),
+    path(r'sites/<slug:slug>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
+    path(r'sites/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
+    path(r'sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
 
 
     # Rack groups
     # Rack groups
-    url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
-    url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
-    url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
-    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+)/changelog/$', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
+    path(r'rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
+    path(r'rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
+    path(r'rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
+    path(r'rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
+    path(r'rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
+    path(r'rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
 
 
     # Rack roles
     # Rack roles
-    url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
-    url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'),
-    url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
-    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+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
+    path(r'rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
+    path(r'rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
+    path(r'rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
+    path(r'rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
+    path(r'rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
+    path(r'rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
 
 
     # Rack reservations
     # Rack reservations
-    url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
-    url(r'^rack-reservations/edit/$', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
-    url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
-    url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
-    url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
-    url(r'^rack-reservations/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
+    path(r'rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
+    path(r'rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
+    path(r'rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
+    path(r'rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
+    path(r'rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
+    path(r'rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
 
 
     # Racks
     # Racks
-    url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
-    url(r'^rack-elevations/$', views.RackElevationListView.as_view(), name='rack_elevation_list'),
-    url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
-    url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'),
-    url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
-    url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
-    url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
-    url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
-    url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
-    url(r'^racks/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
-    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}),
+    path(r'racks/', views.RackListView.as_view(), name='rack_list'),
+    path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
+    path(r'racks/add/', views.RackEditView.as_view(), name='rack_add'),
+    path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
+    path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
+    path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
+    path(r'racks/<int:pk>/', views.RackView.as_view(), name='rack'),
+    path(r'racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
+    path(r'racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
+    path(r'racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
+    path(r'racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
+    path(r'racks/<int:object_id>/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/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
-    url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
-    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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
+    path(r'manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
+    path(r'manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
+    path(r'manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
+    path(r'manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
+    path(r'manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
+    path(r'manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
 
 
     # Device types
     # Device types
-    url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
-    url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
-    url(r'^device-types/import/$', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'),
-    url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
-    url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
-    url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
-    url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
-    url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
-    url(r'^device-types/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
+    path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
+    path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
+    path(r'device-types/import/', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'),
+    path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
+    path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
+    path(r'device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
+    path(r'device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
+    path(r'device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
+    path(r'device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
 
 
     # Console port templates
     # Console port templates
-    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'),
+    path(r'device-types/<int:pk>/console-ports/add/', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
+    path(r'device-types/<int:pk>/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.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'),
+    path(r'device-types/<int:pk>/console-server-ports/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'),
+    path(r'device-types/<int:pk>/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.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'),
+    path(r'device-types/<int:pk>/power-ports/add/', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'),
+    path(r'device-types/<int:pk>/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.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'),
+    path(r'device-types/<int:pk>/power-outlets/add/', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'),
+    path(r'device-types/<int:pk>/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.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'),
-    url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
-    url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
+    path(r'device-types/<int:pk>/interfaces/add/', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'),
+    path(r'device-types/<int:pk>/interfaces/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
+    path(r'device-types/<int:pk>/interfaces/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
 
 
     # Front port templates
     # Front port templates
-    url(r'^device-types/(?P<pk>\d+)/front-ports/add/$', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'),
-    url(r'^device-types/(?P<pk>\d+)/front-ports/delete/$', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'),
+    path(r'device-types/<int:pk>/front-ports/add/', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'),
+    path(r'device-types/<int:pk>/front-ports/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'),
 
 
     # Rear port templates
     # Rear port templates
-    url(r'^device-types/(?P<pk>\d+)/rear-ports/add/$', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'),
-    url(r'^device-types/(?P<pk>\d+)/rear-ports/delete/$', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'),
+    path(r'device-types/<int:pk>/rear-ports/add/', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'),
+    path(r'device-types/<int:pk>/rear-ports/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'),
 
 
     # Device bay templates
     # Device bay templates
-    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'),
+    path(r'device-types/<int:pk>/device-bays/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
+    path(r'device-types/<int:pk>/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/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
-    url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
-    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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
+    path(r'device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
+    path(r'device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
+    path(r'device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
+    path(r'device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
+    path(r'device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
+    path(r'device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
 
 
     # Platforms
     # Platforms
-    url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
-    url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'),
-    url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'),
-    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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
+    path(r'platforms/', views.PlatformListView.as_view(), name='platform_list'),
+    path(r'platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
+    path(r'platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
+    path(r'platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
+    path(r'platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
+    path(r'platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
 
 
     # Devices
     # Devices
-    url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
-    url(r'^devices/add/$', views.DeviceCreateView.as_view(), name='device_add'),
-    url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
-    url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
-    url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
-    url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
-    url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
-    url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
-    url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
-    url(r'^devices/(?P<pk>\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
-    url(r'^devices/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
-    url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
-    url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
-    url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
-    url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
-    url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
-    url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'),
-    url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
+    path(r'devices/', views.DeviceListView.as_view(), name='device_list'),
+    path(r'devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
+    path(r'devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
+    path(r'devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
+    path(r'devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
+    path(r'devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
+    path(r'devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
+    path(r'devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
+    path(r'devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
+    path(r'devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
+    path(r'devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
+    path(r'devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
+    path(r'devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
+    path(r'devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
+    path(r'devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
+    path(r'devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
+    path(r'devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
+    path(r'devices/<int:object_id>/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/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
-    url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
-    url(r'^console-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
-    url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
-    url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
-    url(r'^console-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
+    path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
+    path(r'devices/<int:pk>/console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
+    path(r'devices/<int:pk>/console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
+    path(r'console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
+    path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
+    path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
+    path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
 
 
     # 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/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
-    url(r'^devices/(?P<pk>\d+)/console-server-ports/edit/$', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
-    url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
-    url(r'^console-server-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
-    url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
-    url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
-    url(r'^console-server-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
-    url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
-    url(r'^console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
+    path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
+    path(r'devices/<int:pk>/console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
+    path(r'devices/<int:pk>/console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
+    path(r'devices/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
+    path(r'console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
+    path(r'console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
+    path(r'console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
+    path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
+    path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
+    path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
 
 
     # Power ports
     # Power ports
-    url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
-    url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
-    url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
-    url(r'^power-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
-    url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
-    url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
-    url(r'^power-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
+    path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
+    path(r'devices/<int:pk>/power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
+    path(r'devices/<int:pk>/power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
+    path(r'power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
+    path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
+    path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
+    path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
 
 
     # Power outlets
     # Power outlets
-    url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
-    url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
-    url(r'^devices/(?P<pk>\d+)/power-outlets/edit/$', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
-    url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
-    url(r'^power-outlets/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
-    url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
-    url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
-    url(r'^power-outlets/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
-    url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
-    url(r'^power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
+    path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
+    path(r'devices/<int:pk>/power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
+    path(r'devices/<int:pk>/power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
+    path(r'devices/<int:pk>/power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
+    path(r'power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
+    path(r'power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
+    path(r'power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
+    path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
+    path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
+    path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
 
 
     # Interfaces
     # Interfaces
-    url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
-    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/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    url(r'^interfaces/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
-    url(r'^interfaces/(?P<pk>\d+)/$', views.InterfaceView.as_view(), name='interface'),
-    url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
-    url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
-    url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
-    url(r'^interfaces/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
-    url(r'^interfaces/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
-    url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
-    url(r'^interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
+    path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
+    path(r'devices/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
+    path(r'devices/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
+    path(r'devices/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
+    path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
+    path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
+    path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
+    path(r'interfaces/<int:pk>/assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
+    path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
+    path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
+    path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
+    path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
+    path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
 
 
     # Front ports
     # Front ports
-    # url(r'^devices/front-ports/add/$', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
-    url(r'^devices/(?P<pk>\d+)/front-ports/add/$', views.FrontPortCreateView.as_view(), name='frontport_add'),
-    url(r'^devices/(?P<pk>\d+)/front-ports/edit/$', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
-    url(r'^devices/(?P<pk>\d+)/front-ports/delete/$', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
-    url(r'^front-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
-    url(r'^front-ports/(?P<pk>\d+)/edit/$', views.FrontPortEditView.as_view(), name='frontport_edit'),
-    url(r'^front-ports/(?P<pk>\d+)/delete/$', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
-    url(r'^front-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
-    url(r'^front-ports/rename/$', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
-    url(r'^front-ports/disconnect/$', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
+    # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
+    path(r'devices/<int:pk>/front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
+    path(r'devices/<int:pk>/front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
+    path(r'devices/<int:pk>/front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
+    path(r'front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
+    path(r'front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
+    path(r'front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
+    path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
+    path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
+    path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
 
 
     # Rear ports
     # Rear ports
-    # url(r'^devices/rear-ports/add/$', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
-    url(r'^devices/(?P<pk>\d+)/rear-ports/add/$', views.RearPortCreateView.as_view(), name='rearport_add'),
-    url(r'^devices/(?P<pk>\d+)/rear-ports/edit/$', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
-    url(r'^devices/(?P<pk>\d+)/rear-ports/delete/$', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
-    url(r'^rear-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
-    url(r'^rear-ports/(?P<pk>\d+)/edit/$', views.RearPortEditView.as_view(), name='rearport_edit'),
-    url(r'^rear-ports/(?P<pk>\d+)/delete/$', views.RearPortDeleteView.as_view(), name='rearport_delete'),
-    url(r'^rear-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
-    url(r'^rear-ports/rename/$', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
-    url(r'^rear-ports/disconnect/$', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
+    # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
+    path(r'devices/<int:pk>/rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
+    path(r'devices/<int:pk>/rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
+    path(r'devices/<int:pk>/rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
+    path(r'rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
+    path(r'rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
+    path(r'rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
+    path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
+    path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
+    path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
 
 
     # Device bays
     # Device bays
-    url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
-    url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
-    url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
-    url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
-    url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
-    url(r'^device-bays/(?P<pk>\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
-    url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
-    url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
+    path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
+    path(r'devices/<int:pk>/bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
+    path(r'devices/<int:pk>/bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
+    path(r'device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
+    path(r'device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
+    path(r'device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
+    path(r'device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
+    path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
 
 
     # Inventory items
     # Inventory items
-    url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
-    url(r'^inventory-items/import/$', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
-    url(r'^inventory-items/edit/$', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
-    url(r'^inventory-items/delete/$', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
-    url(r'^inventory-items/(?P<pk>\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
-    url(r'^inventory-items/(?P<pk>\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
-    url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
+    path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
+    path(r'inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
+    path(r'inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
+    path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
+    path(r'inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
+    path(r'inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
+    path(r'devices/<int:device>/inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
 
 
     # Cables
     # Cables
-    url(r'^cables/$', views.CableListView.as_view(), name='cable_list'),
-    url(r'^cables/import/$', views.CableBulkImportView.as_view(), name='cable_import'),
-    url(r'^cables/edit/$', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
-    url(r'^cables/delete/$', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
-    url(r'^cables/(?P<pk>\d+)/$', views.CableView.as_view(), name='cable'),
-    url(r'^cables/(?P<pk>\d+)/edit/$', views.CableEditView.as_view(), name='cable_edit'),
-    url(r'^cables/(?P<pk>\d+)/delete/$', views.CableDeleteView.as_view(), name='cable_delete'),
-    url(r'^cables/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
+    path(r'cables/', views.CableListView.as_view(), name='cable_list'),
+    path(r'cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
+    path(r'cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
+    path(r'cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
+    path(r'cables/<int:pk>/', views.CableView.as_view(), name='cable'),
+    path(r'cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
+    path(r'cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
+    path(r'cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
 
 
     # Console/power/interface connections (read-only)
     # Console/power/interface connections (read-only)
-    url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
-    url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
-    url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
+    path(r'console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
+    path(r'power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
+    path(r'interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
 
 
     # Virtual chassis
     # Virtual chassis
-    url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
-    url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
-    url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
-    url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
-    url(r'^virtual-chassis/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
-    url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
-    url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
+    path(r'virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
+    path(r'virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
+    path(r'virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
+    path(r'virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
+    path(r'virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
+    path(r'virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
+    path(r'virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
 
 
     # Power panels
     # Power panels
-    url(r'^power-panels/$', views.PowerPanelListView.as_view(), name='powerpanel_list'),
-    url(r'^power-panels/add/$', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
-    url(r'^power-panels/import/$', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
-    url(r'^power-panels/delete/$', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
-    url(r'^power-panels/(?P<pk>\d+)/$', views.PowerPanelView.as_view(), name='powerpanel'),
-    url(r'^power-panels/(?P<pk>\d+)/edit/$', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
-    url(r'^power-panels/(?P<pk>\d+)/delete/$', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
-    url(r'^power-panels/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
+    path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
+    path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
+    path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
+    path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
+    path(r'power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
+    path(r'power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
+    path(r'power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
+    path(r'power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
 
 
     # Power feeds
     # Power feeds
-    url(r'^power-feeds/$', views.PowerFeedListView.as_view(), name='powerfeed_list'),
-    url(r'^power-feeds/add/$', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
-    url(r'^power-feeds/import/$', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
-    url(r'^power-feeds/edit/$', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
-    url(r'^power-feeds/delete/$', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
-    url(r'^power-feeds/(?P<pk>\d+)/$', views.PowerFeedView.as_view(), name='powerfeed'),
-    url(r'^power-feeds/(?P<pk>\d+)/edit/$', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
-    url(r'^power-feeds/(?P<pk>\d+)/delete/$', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
-    url(r'^power-feeds/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
+    path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
+    path(r'power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
+    path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
+    path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
+    path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
+    path(r'power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
+    path(r'power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
+    path(r'power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
+    path(r'power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
 
 
 ]
 ]

+ 10 - 1
netbox/dcim/views.py

@@ -253,6 +253,14 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
     default_return_url = 'dcim:site_list'
     default_return_url = 'dcim:site_list'
 
 
 
 
+class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_site'
+    queryset = Site.objects.select_related('region', 'tenant')
+    filter = filters.SiteFilter
+    table = tables.SiteTable
+    default_return_url = 'dcim:site_list'
+
+
 #
 #
 # Rack groups
 # Rack groups
 #
 #
@@ -464,7 +472,7 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class RackReservationListView(PermissionRequiredMixin, ObjectListView):
 class RackReservationListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_rackreservation'
     permission_required = 'dcim.view_rackreservation'
-    queryset = RackReservation.objects.all()
+    queryset = RackReservation.objects.select_related('rack__site')
     filter = filters.RackReservationFilter
     filter = filters.RackReservationFilter
     filter_form = forms.RackReservationFilterForm
     filter_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
     table = tables.RackReservationTable
@@ -523,6 +531,7 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_manufacturer'
     permission_required = 'dcim.view_manufacturer'
     queryset = Manufacturer.objects.annotate(
     queryset = Manufacturer.objects.annotate(
         devicetype_count=Count('device_types', distinct=True),
         devicetype_count=Count('device_types', distinct=True),
+        inventoryitem_count=Count('inventory_items', distinct=True),
         platform_count=Count('platforms', distinct=True),
         platform_count=Count('platforms', distinct=True),
     )
     )
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable

+ 3 - 7
netbox/extras/middleware.py

@@ -30,10 +30,6 @@ def cache_changed_object(instance, **kwargs):
 
 
 def _record_object_deleted(request, instance, **kwargs):
 def _record_object_deleted(request, instance, **kwargs):
 
 
-    # Force resolution of request.user in case it's still a SimpleLazyObject. This seems to happen
-    # occasionally during tests, but haven't been able to determine why.
-    assert request.user.is_authenticated
-
     # Record that the object was deleted
     # Record that the object was deleted
     if hasattr(instance, 'log_change'):
     if hasattr(instance, 'log_change'):
         instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
         instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
@@ -53,7 +49,7 @@ class ObjectChangeMiddleware(object):
         2. Enqueue any relevant webhooks.
         2. Enqueue any relevant webhooks.
         3. Increment metric counter for the event type
         3. Increment metric counter for the event type
 
 
-    The post_save and pre_delete signals are employed to catch object modifications, however changes are recorded a bit
+    The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit
     differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
     differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
     completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags)
     completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags)
     have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
     have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
@@ -71,10 +67,10 @@ class ObjectChangeMiddleware(object):
         # the same request.
         # the same request.
         request.id = uuid.uuid4()
         request.id = uuid.uuid4()
 
 
-        # Signals don't include the request context, so we're currying it into the pre_delete function ahead of time.
+        # Signals don't include the request context, so we're currying it into the post_delete function ahead of time.
         record_object_deleted = curry(_record_object_deleted, request)
         record_object_deleted = curry(_record_object_deleted, request)
 
 
-        # Connect our receivers to the post_save and pre_delete signals.
+        # Connect our receivers to the post_save and post_delete signals.
         post_save.connect(cache_changed_object, dispatch_uid='record_object_saved')
         post_save.connect(cache_changed_object, dispatch_uid='record_object_saved')
         post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted')
         post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted')
 
 

+ 9 - 4
netbox/extras/models.py

@@ -108,17 +108,22 @@ class Webhook(models.Model):
 #
 #
 
 
 class CustomFieldModel(models.Model):
 class CustomFieldModel(models.Model):
+    _cf = None
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
+    @property
     def cf(self):
     def cf(self):
         """
         """
         Name-based CustomFieldValue accessor for use in templates
         Name-based CustomFieldValue accessor for use in templates
         """
         """
-        if not hasattr(self, 'get_custom_fields'):
-            return dict()
-        return {field.name: value for field, value in self.get_custom_fields().items()}
+        if self._cf is None:
+            # Cache all custom field values for this instance
+            self._cf = {
+                field.name: value for field, value in self.get_custom_fields().items()
+            }
+        return self._cf
 
 
     def get_custom_fields(self):
     def get_custom_fields(self):
         """
         """
@@ -131,7 +136,7 @@ class CustomFieldModel(models.Model):
 
 
         # If the object exists, populate its custom fields with values
         # If the object exists, populate its custom fields with values
         if hasattr(self, 'pk'):
         if hasattr(self, 'pk'):
-            values = CustomFieldValue.objects.filter(obj_type=content_type, obj_id=self.pk).select_related('field')
+            values = self.custom_field_values.all()
             values_dict = {cfv.field_id: cfv.value for cfv in values}
             values_dict = {cfv.field_id: cfv.value for cfv in values}
             return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
             return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
         else:
         else:

+ 18 - 0
netbox/extras/querysets.py

@@ -1,6 +1,24 @@
+from collections import OrderedDict
+
 from django.db.models import Q, QuerySet
 from django.db.models import Q, QuerySet
 
 
 
 
+class CustomFieldQueryset:
+    """
+    Annotate custom fields on objects within a QuerySet.
+    """
+    def __init__(self, queryset, custom_fields):
+        self.queryset = queryset
+        self.model = queryset.model
+        self.custom_fields = custom_fields
+
+    def __iter__(self):
+        for obj in self.queryset:
+            values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()}
+            obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields])
+            yield obj
+
+
 class ConfigContextQuerySet(QuerySet):
 class ConfigContextQuerySet(QuerySet):
 
 
     def get_for_object(self, obj):
     def get_for_object(self, obj):

+ 21 - 21
netbox/extras/urls.py

@@ -1,4 +1,4 @@
-from django.conf.urls import url
+from django.urls import path
 
 
 from extras import views
 from extras import views
 from extras.models import Tag
 from extras.models import Tag
@@ -8,33 +8,33 @@ app_name = 'extras'
 urlpatterns = [
 urlpatterns = [
 
 
     # Tags
     # Tags
-    url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
-    url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
-    url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
-    url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
-    url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
-    url(r'^tags/(?P<slug>[\w-]+)/changelog/$', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
+    path(r'tags/', views.TagListView.as_view(), name='tag_list'),
+    path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
+    path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
+    path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
+    path(r'tags/<slug:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
+    path(r'tags/<slug:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
 
 
     # Config contexts
     # Config contexts
-    url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
-    url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
-    url(r'^config-contexts/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
-    url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
-    url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
-    url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
-    url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
+    path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
+    path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
+    path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
+    path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
+    path(r'config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
+    path(r'config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
+    path(r'config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
 
 
     # Image attachments
     # Image attachments
-    url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
-    url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
+    path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
+    path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
 
 
     # Reports
     # Reports
-    url(r'^reports/$', views.ReportListView.as_view(), name='report_list'),
-    url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
-    url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
+    path(r'reports/', views.ReportListView.as_view(), name='report_list'),
+    path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
+    path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
 
 
     # Change logging
     # Change logging
-    url(r'^changelog/$', views.ObjectChangeListView.as_view(), name='objectchange_list'),
-    url(r'^changelog/(?P<pk>\d+)/$', views.ObjectChangeView.as_view(), name='objectchange'),
+    path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
+    path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
 
 
 ]
 ]

+ 11 - 45
netbox/ipam/filters.py

@@ -6,14 +6,14 @@ from netaddr.core import AddrFormatError
 
 
 from dcim.models import Site, Device, Interface
 from dcim.models import Site, Device, Interface
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
-from tenancy.models import Tenant
+from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
 from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
 
 
-class VRFFilter(CustomFieldFilterSet):
+class VRFFilter(TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -22,16 +22,6 @@ class VRFFilter(CustomFieldFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
@@ -120,7 +110,7 @@ class RoleFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class PrefixFilter(CustomFieldFilterSet):
+class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -159,16 +149,6 @@ class PrefixFilter(CustomFieldFilterSet):
         to_field_name='rd',
         to_field_name='rd',
         label='VRF (RD)',
         label='VRF (RD)',
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -267,7 +247,7 @@ class PrefixFilter(CustomFieldFilterSet):
         return queryset.filter(prefix__net_mask_length=value)
         return queryset.filter(prefix__net_mask_length=value)
 
 
 
 
-class IPAddressFilter(CustomFieldFilterSet):
+class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -298,16 +278,6 @@ class IPAddressFilter(CustomFieldFilterSet):
         to_field_name='rd',
         to_field_name='rd',
         label='VRF (RD)',
         label='VRF (RD)',
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     device = django_filters.CharFilter(
     device = django_filters.CharFilter(
         method='filter_device',
         method='filter_device',
         field_name='name',
         field_name='name',
@@ -329,6 +299,12 @@ class IPAddressFilter(CustomFieldFilterSet):
         to_field_name='name',
         to_field_name='name',
         label='Virtual machine (name)',
         label='Virtual machine (name)',
     )
     )
+    interface = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface__name',
+        queryset=Interface.objects.all(),
+        to_field_name='name',
+        label='Interface (ID)',
+    )
     interface_id = django_filters.ModelMultipleChoiceFilter(
     interface_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Interface (ID)',
         label='Interface (ID)',
@@ -408,7 +384,7 @@ class VLANGroupFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']
 
 
 
 
-class VLANFilter(CustomFieldFilterSet):
+class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -437,16 +413,6 @@ class VLANFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Group',
         label='Group',
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     role_id = django_filters.ModelMultipleChoiceFilter(
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         label='Role (ID)',
         label='Role (ID)',

+ 14 - 44
netbox/ipam/forms.py

@@ -6,6 +6,7 @@ from taggit.forms import TagField
 from dcim.models import Site, Rack, Device, Interface
 from dcim.models import Site, Rack, Device, Interface
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
+from tenancy.forms import TenancyFilterForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
@@ -97,22 +98,13 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm
         ]
         ]
 
 
 
 
-class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = VRF
     model = VRF
+    field_order = ['q', 'tenant_group', 'tenant']
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/tenancy/tenants/",
-            value_field="slug",
-            null_option=True,
-        )
-    )
 
 
 
 
 #
 #
@@ -497,8 +489,12 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         ]
         ]
 
 
 
 
-class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = Prefix
     model = Prefix
+    field_order = [
+        'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'site', 'role', 'tenant_group', 'tenant',
+        'is_pool', 'expand',
+    ]
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
@@ -533,16 +529,6 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/tenancy/tenants/",
-            value_field="slug",
-            null_option=True,
-        )
-    )
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         choices=PREFIX_STATUS_CHOICES,
         choices=PREFIX_STATUS_CHOICES,
         required=False,
         required=False,
@@ -949,8 +935,11 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
-class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = IPAddress
     model = IPAddress
+    field_order = [
+        'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'tenant_group', 'tenant',
+    ]
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
@@ -985,16 +974,6 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/tenancy/tenants/",
-            value_field="slug",
-            null_option=True,
-        )
-    )
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         choices=IPADDRESS_STATUS_CHOICES,
         choices=IPADDRESS_STATUS_CHOICES,
         required=False,
         required=False,
@@ -1226,8 +1205,9 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         ]
         ]
 
 
 
 
-class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = VLAN
     model = VLAN
+    field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
@@ -1251,16 +1231,6 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url="/api/tenancy/tenants/",
-            value_field="slug",
-            null_option=True,
-        )
-    )
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         choices=VLAN_STATUS_CHOICES,
         choices=VLAN_STATUS_CHOICES,
         required=False,
         required=False,

+ 3 - 0
netbox/ipam/tables.py

@@ -319,6 +319,7 @@ class PrefixTable(BaseTable):
 
 
 class PrefixDetailTable(PrefixTable):
 class PrefixDetailTable(PrefixTable):
     utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False)
     utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False)
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
 
 
     class Meta(PrefixTable.Meta):
     class Meta(PrefixTable.Meta):
         fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description')
         fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description')
@@ -351,6 +352,7 @@ class IPAddressDetailTable(IPAddressTable):
     nat_inside = tables.LinkColumn(
     nat_inside = tables.LinkColumn(
         'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
         'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
     )
     )
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
 
 
     class Meta(IPAddressTable.Meta):
     class Meta(IPAddressTable.Meta):
         fields = (
         fields = (
@@ -426,6 +428,7 @@ class VLANTable(BaseTable):
 
 
 class VLANDetailTable(VLANTable):
 class VLANDetailTable(VLANTable):
     prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
     prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
+    tenant = tables.TemplateColumn(template_code=COL_TENANT)
 
 
     class Meta(VLANTable.Meta):
     class Meta(VLANTable.Meta):
         fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
         fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')

+ 18 - 0
netbox/ipam/tests/test_api.py

@@ -1,3 +1,5 @@
+import json
+
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 from rest_framework import status
 from rest_framework import status
@@ -870,6 +872,8 @@ class VLANTest(APITestCase):
         self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2')
         self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2')
         self.vlan3 = VLAN.objects.create(vid=3, name='Test VLAN 3')
         self.vlan3 = VLAN.objects.create(vid=3, name='Test VLAN 3')
 
 
+        self.prefix1 = Prefix.objects.create(prefix=IPNetwork('192.168.1.0/24'))
+
     def test_get_vlan(self):
     def test_get_vlan(self):
 
 
         url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
         url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
@@ -960,6 +964,20 @@ class VLANTest(APITestCase):
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertEqual(VLAN.objects.count(), 2)
         self.assertEqual(VLAN.objects.count(), 2)
 
 
+    def test_delete_vlan_with_prefix(self):
+        self.prefix1.vlan = self.vlan1
+        self.prefix1.save()
+
+        url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
+        response = self.client.delete(url, **self.header)
+
+        # can't use assertHttpStatus here because we don't have response.data
+        self.assertEqual(response.status_code, 409)
+
+        content = json.loads(response.content.decode('utf-8'))
+        self.assertIn('detail', content)
+        self.assertTrue(content['detail'].startswith('Unable to delete object.'))
+
 
 
 class ServiceTest(APITestCase):
 class ServiceTest(APITestCase):
 
 

+ 77 - 77
netbox/ipam/urls.py

@@ -1,4 +1,4 @@
-from django.conf.urls import url
+from django.urls import path
 
 
 from extras.views import ObjectChangeLogView
 from extras.views import ObjectChangeLogView
 from . import views
 from . import views
@@ -8,97 +8,97 @@ app_name = 'ipam'
 urlpatterns = [
 urlpatterns = [
 
 
     # VRFs
     # VRFs
-    url(r'^vrfs/$', views.VRFListView.as_view(), name='vrf_list'),
-    url(r'^vrfs/add/$', views.VRFCreateView.as_view(), name='vrf_add'),
-    url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
-    url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
-    url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
-    url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'),
-    url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'),
-    url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'),
-    url(r'^vrfs/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
+    path(r'vrfs/', views.VRFListView.as_view(), name='vrf_list'),
+    path(r'vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'),
+    path(r'vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
+    path(r'vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
+    path(r'vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
+    path(r'vrfs/<int:pk>/', views.VRFView.as_view(), name='vrf'),
+    path(r'vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
+    path(r'vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
+    path(r'vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
 
 
     # RIRs
     # RIRs
-    url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
-    url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'),
-    url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'),
-    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'^vrfs/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
+    path(r'rirs/', views.RIRListView.as_view(), name='rir_list'),
+    path(r'rirs/add/', views.RIRCreateView.as_view(), name='rir_add'),
+    path(r'rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
+    path(r'rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
+    path(r'rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
+    path(r'vrfs/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
 
 
     # Aggregates
     # Aggregates
-    url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'),
-    url(r'^aggregates/add/$', views.AggregateCreateView.as_view(), name='aggregate_add'),
-    url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
-    url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
-    url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
-    url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'),
-    url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'),
-    url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
-    url(r'^aggregates/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
+    path(r'aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
+    path(r'aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'),
+    path(r'aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
+    path(r'aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
+    path(r'aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
+    path(r'aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
+    path(r'aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
+    path(r'aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
+    path(r'aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
 
 
     # Roles
     # Roles
-    url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
-    url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'),
-    url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'),
-    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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
+    path(r'roles/', views.RoleListView.as_view(), name='role_list'),
+    path(r'roles/add/', views.RoleCreateView.as_view(), name='role_add'),
+    path(r'roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
+    path(r'roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
+    path(r'roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
+    path(r'roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
 
 
     # Prefixes
     # Prefixes
-    url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'),
-    url(r'^prefixes/add/$', views.PrefixCreateView.as_view(), name='prefix_add'),
-    url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
-    url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
-    url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
-    url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
-    url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
-    url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
-    url(r'^prefixes/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
-    url(r'^prefixes/(?P<pk>\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
-    url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
+    path(r'prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
+    path(r'prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'),
+    path(r'prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
+    path(r'prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
+    path(r'prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
+    path(r'prefixes/<int:pk>/', views.PrefixView.as_view(), name='prefix'),
+    path(r'prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
+    path(r'prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
+    path(r'prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
+    path(r'prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
+    path(r'prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
 
 
     # IP addresses
     # IP addresses
-    url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
-    url(r'^ip-addresses/add/$', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
-    url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
-    url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
-    url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
-    url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
-    url(r'^ip-addresses/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
-    url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
-    url(r'^ip-addresses/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
-    url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
-    url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
+    path(r'ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
+    path(r'ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
+    path(r'ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
+    path(r'ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
+    path(r'ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
+    path(r'ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
+    path(r'ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
+    path(r'ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
+    path(r'ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
+    path(r'ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
+    path(r'ip-addresses/<int:pk>/delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
 
 
     # VLAN groups
     # VLAN groups
-    url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
-    url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
-    url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
-    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+)/vlans/$', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
-    url(r'^vlan-groups/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
+    path(r'vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
+    path(r'vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
+    path(r'vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
+    path(r'vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
+    path(r'vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
+    path(r'vlan-groups/<int:pk>/vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
+    path(r'vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
 
 
     # VLANs
     # VLANs
-    url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
-    url(r'^vlans/add/$', views.VLANCreateView.as_view(), name='vlan_add'),
-    url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
-    url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
-    url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
-    url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
-    url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
-    url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
-    url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
-    url(r'^vlans/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
+    path(r'vlans/', views.VLANListView.as_view(), name='vlan_list'),
+    path(r'vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'),
+    path(r'vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
+    path(r'vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
+    path(r'vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
+    path(r'vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
+    path(r'vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
+    path(r'vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
+    path(r'vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
+    path(r'vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
 
 
     # Services
     # Services
-    url(r'^services/$', views.ServiceListView.as_view(), name='service_list'),
-    url(r'^services/edit/$', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
-    url(r'^services/delete/$', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
-    url(r'^services/(?P<pk>\d+)/$', views.ServiceView.as_view(), name='service'),
-    url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'),
-    url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'),
-    url(r'^services/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
+    path(r'services/', views.ServiceListView.as_view(), name='service_list'),
+    path(r'services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
+    path(r'services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
+    path(r'services/<int:pk>/', views.ServiceView.as_view(), name='service'),
+    path(r'services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
+    path(r'services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
+    path(r'services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
 
 
 ]
 ]

+ 31 - 30
netbox/netbox/urls.py

@@ -1,5 +1,6 @@
 from django.conf import settings
 from django.conf import settings
-from django.conf.urls import include, url
+from django.conf.urls import include
+from django.urls import path, re_path
 from django.views.static import serve
 from django.views.static import serve
 from drf_yasg import openapi
 from drf_yasg import openapi
 from drf_yasg.views import get_schema_view
 from drf_yasg.views import get_schema_view
@@ -24,63 +25,63 @@ schema_view = get_schema_view(
 _patterns = [
 _patterns = [
 
 
     # Base views
     # Base views
-    url(r'^$', HomeView.as_view(), name='home'),
-    url(r'^search/$', SearchView.as_view(), name='search'),
+    path(r'', HomeView.as_view(), name='home'),
+    path(r'search/', SearchView.as_view(), name='search'),
 
 
     # Login/logout
     # Login/logout
-    url(r'^login/$', LoginView.as_view(), name='login'),
-    url(r'^logout/$', LogoutView.as_view(), name='logout'),
+    path(r'login/', LoginView.as_view(), name='login'),
+    path(r'logout/', LogoutView.as_view(), name='logout'),
 
 
     # Apps
     # Apps
-    url(r'^circuits/', include('circuits.urls')),
-    url(r'^dcim/', include('dcim.urls')),
-    url(r'^extras/', include('extras.urls')),
-    url(r'^ipam/', include('ipam.urls')),
-    url(r'^secrets/', include('secrets.urls')),
-    url(r'^tenancy/', include('tenancy.urls')),
-    url(r'^user/', include('users.urls')),
-    url(r'^virtualization/', include('virtualization.urls')),
+    path(r'circuits/', include('circuits.urls')),
+    path(r'dcim/', include('dcim.urls')),
+    path(r'extras/', include('extras.urls')),
+    path(r'ipam/', include('ipam.urls')),
+    path(r'secrets/', include('secrets.urls')),
+    path(r'tenancy/', include('tenancy.urls')),
+    path(r'user/', include('users.urls')),
+    path(r'virtualization/', include('virtualization.urls')),
 
 
     # API
     # API
-    url(r'^api/$', APIRootView.as_view(), name='api-root'),
-    url(r'^api/circuits/', include('circuits.api.urls')),
-    url(r'^api/dcim/', include('dcim.api.urls')),
-    url(r'^api/extras/', include('extras.api.urls')),
-    url(r'^api/ipam/', include('ipam.api.urls')),
-    url(r'^api/secrets/', include('secrets.api.urls')),
-    url(r'^api/tenancy/', include('tenancy.api.urls')),
-    url(r'^api/virtualization/', include('virtualization.api.urls')),
-    url(r'^api/docs/$', schema_view.with_ui('swagger'), name='api_docs'),
-    url(r'^api/redoc/$', schema_view.with_ui('redoc'), name='api_redocs'),
-    url(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
+    path(r'api/', APIRootView.as_view(), name='api-root'),
+    path(r'api/circuits/', include('circuits.api.urls')),
+    path(r'api/dcim/', include('dcim.api.urls')),
+    path(r'api/extras/', include('extras.api.urls')),
+    path(r'api/ipam/', include('ipam.api.urls')),
+    path(r'api/secrets/', include('secrets.api.urls')),
+    path(r'api/tenancy/', include('tenancy.api.urls')),
+    path(r'api/virtualization/', include('virtualization.api.urls')),
+    path(r'api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
+    path(r'api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
+    re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
 
 
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
-    url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
+    path(r'media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
 
 
     # Admin
     # Admin
-    url(r'^admin/', admin_site.urls),
+    path(r'admin/', admin_site.urls),
 
 
 ]
 ]
 
 
 if settings.WEBHOOKS_ENABLED:
 if settings.WEBHOOKS_ENABLED:
     _patterns += [
     _patterns += [
-        url(r'^admin/webhook-backend-status/', include('django_rq.urls')),
+        path(r'admin/webhook-backend-status/', include('django_rq.urls')),
     ]
     ]
 
 
 if settings.DEBUG:
 if settings.DEBUG:
     import debug_toolbar
     import debug_toolbar
     _patterns += [
     _patterns += [
-        url(r'^__debug__/', include(debug_toolbar.urls)),
+        path(r'__debug__/', include(debug_toolbar.urls)),
     ]
     ]
 
 
 if settings.METRICS_ENABLED:
 if settings.METRICS_ENABLED:
     _patterns += [
     _patterns += [
-        url('', include('django_prometheus.urls')),
+        path('', include('django_prometheus.urls')),
     ]
     ]
 
 
 # Prepend BASE_PATH
 # Prepend BASE_PATH
 urlpatterns = [
 urlpatterns = [
-    url(r'^{}'.format(settings.BASE_PATH), include(_patterns))
+    path(r'{}'.format(settings.BASE_PATH), include(_patterns))
 ]
 ]
 
 
 handler500 = 'utilities.views.server_error'
 handler500 = 'utilities.views.server_error'

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

@@ -559,6 +559,7 @@ table.report th a {
 .color-block {
 .color-block {
     display: block;
     display: block;
     width: 80px;
     width: 80px;
+    border: 1px solid grey;
 }
 }
 .text-nowrap {
 .text-nowrap {
     white-space: nowrap;
     white-space: nowrap;

+ 4 - 0
netbox/project-static/js/forms.js

@@ -267,6 +267,10 @@ $(document).ready(function() {
 
 
             processResults: function (data) {
             processResults: function (data) {
                 var results = $.map(data.results, function (obj) {
                 var results = $.map(data.results, function (obj) {
+                    // If tag contains space add double quotes
+                    if (/\s/.test(obj.name))
+                        obj.name = '"' + obj.name + '"'
+
                     return {
                     return {
                         id: obj.name,
                         id: obj.name,
                         text: obj.name
                         text: obj.name

+ 15 - 15
netbox/secrets/urls.py

@@ -1,4 +1,4 @@
-from django.conf.urls import url
+from django.urls import path
 
 
 from extras.views import ObjectChangeLogView
 from extras.views import ObjectChangeLogView
 from . import views
 from . import views
@@ -8,21 +8,21 @@ app_name = 'secrets'
 urlpatterns = [
 urlpatterns = [
 
 
     # Secret roles
     # Secret roles
-    url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'),
-    url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
-    url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
-    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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
+    path(r'secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'),
+    path(r'secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
+    path(r'secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
+    path(r'secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
+    path(r'secret-roles/<slug:slug>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
+    path(r'secret-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
 
 
     # Secrets
     # Secrets
-    url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'),
-    url(r'^secrets/import/$', views.SecretBulkImportView.as_view(), name='secret_import'),
-    url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
-    url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
-    url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),
-    url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'),
-    url(r'^secrets/(?P<pk>\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'),
-    url(r'^secrets/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
+    path(r'secrets/', views.SecretListView.as_view(), name='secret_list'),
+    path(r'secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'),
+    path(r'secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
+    path(r'secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
+    path(r'secrets/<int:pk>/', views.SecretView.as_view(), name='secret'),
+    path(r'secrets/<int:pk>/edit/', views.secret_edit, name='secret_edit'),
+    path(r'secrets/<int:pk>/delete/', views.SecretDeleteView.as_view(), name='secret_delete'),
+    path(r'secrets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
 
 
 ]
 ]

+ 1 - 1
netbox/templates/dcim/cable_trace.html

@@ -31,7 +31,7 @@
                     </h4>
                     </h4>
                     <p><span class="label label-{% if cable.status %}success{% else %}info{% endif %}">{{ cable.get_status_display }}</span></p>
                     <p><span class="label label-{% if cable.status %}success{% else %}info{% endif %}">{{ cable.get_status_display }}</span></p>
                     <p>{{ cable.get_type_display|default:"" }}</p>
                     <p>{{ cable.get_type_display|default:"" }}</p>
-                    {% if cable.length %}- {{ cable.length }}{{ cable.get_length_unit_display }}{% endif %}
+                    {% if cable.length %}{{ cable.length }} {{ cable.get_length_unit_display }}{% endif %}
                     <span class="label color-block center-block" style="background-color: #{{ cable.color }}">&nbsp;</span>
                     <span class="label color-block center-block" style="background-color: #{{ cable.color }}">&nbsp;</span>
                 {% else %}
                 {% else %}
                     <h4 class="text-muted">No Cable</h4>
                     <h4 class="text-muted">No Cable</h4>

+ 1 - 1
netbox/templates/dcim/site_list.html

@@ -12,7 +12,7 @@
 <h1>{% block title %}Sites{% endblock %}</h1>
 <h1>{% block title %}Sites{% endblock %}</h1>
 <div class="row">
 <div class="row">
 	<div class="col-md-9">
 	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' %}
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' bulk_delete_url='dcim:site_bulk_delete' %}
     </div>
     </div>
     <div class="col-md-3 noprint">
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}

+ 6 - 1
netbox/templates/inc/nav_menu.html

@@ -435,7 +435,12 @@
                         </ul>
                         </ul>
                     </li>
                     </li>
                 {% else %}
                 {% else %}
-                    <li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in"></i> Log in</a></li>
+                    {% url 'login' as login_url %}
+                    {% if request.path == login_url %}
+                        <li><a href="{{ request.get_full_path }}"><i class="fa fa-sign-in"></i> Log in</a></li>
+                    {% else %}
+                        <li><a href="{{ login_url }}?next={{ request.get_full_path | urlencode }}"><i class="fa fa-sign-in"></i> Log in</a></li>
+                    {% endif %}
                 {% endif %}
                 {% endif %}
             </ul>
             </ul>
             <form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" id="navbar_search" role="search">
             <form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" id="navbar_search" role="search">

+ 1 - 0
netbox/templates/login.html

@@ -25,6 +25,7 @@
                 <div class="panel-body">
                 <div class="panel-body">
                     {% csrf_token %}
                     {% csrf_token %}
                     {% if 'next' in request.GET %}<input type="hidden" name="next" value="{{ request.GET.next }}" />{% endif %}
                     {% if 'next' in request.GET %}<input type="hidden" name="next" value="{{ request.GET.next }}" />{% endif %}
+                    {% if 'next' in request.POST %}<input type="hidden" name="next" value="{{ request.POST.next }}" />{% endif %}
                     {% render_form form %}
                     {% render_form form %}
                 </div>
                 </div>
                 <div class="panel-footer text-right">
                 <div class="panel-footer text-right">

+ 27 - 0
netbox/tenancy/filtersets.py

@@ -0,0 +1,27 @@
+import django_filters
+from .models import Tenant, TenantGroup
+
+
+class TenancyFilterSet(django_filters.FilterSet):
+    tenant_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='tenant__group__id',
+        queryset=TenantGroup.objects.all(),
+        to_field_name='id',
+        label='Tenant Group (ID)',
+    )
+    tenant_group = django_filters.ModelMultipleChoiceFilter(
+        field_name='tenant__group__slug',
+        queryset=TenantGroup.objects.all(),
+        to_field_name='slug',
+        label='Tenant Group (slug)',
+    )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        field_name='tenant__slug',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )

+ 27 - 2
netbox/tenancy/forms.py

@@ -1,5 +1,4 @@
 from django import forms
 from django import forms
-from django.db.models import Count
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@@ -117,7 +116,7 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
 
 
 
 
 #
 #
-# Tenancy form extension
+# Form extensions
 #
 #
 
 
 class TenancyForm(ChainedFieldsMixin, forms.Form):
 class TenancyForm(ChainedFieldsMixin, forms.Form):
@@ -155,3 +154,29 @@ class TenancyForm(ChainedFieldsMixin, forms.Form):
             kwargs['initial'] = initial
             kwargs['initial'] = initial
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
+
+
+class TenancyFilterForm(forms.Form):
+    tenant_group = FilterChoiceField(
+        queryset=TenantGroup.objects.all(),
+        to_field_name='slug',
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenant-groups/",
+            value_field="slug",
+            null_option=True,
+            filter_for={
+                'tenant': 'group'
+            }
+        )
+    )
+    tenant = FilterChoiceField(
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenants/",
+            value_field="slug",
+            null_option=True,
+        )
+    )

+ 16 - 16
netbox/tenancy/urls.py

@@ -1,4 +1,4 @@
-from django.conf.urls import url
+from django.urls import path
 
 
 from extras.views import ObjectChangeLogView
 from extras.views import ObjectChangeLogView
 from . import views
 from . import views
@@ -8,22 +8,22 @@ app_name = 'tenancy'
 urlpatterns = [
 urlpatterns = [
 
 
     # Tenant groups
     # Tenant groups
-    url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
-    url(r'^tenant-groups/add/$', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'),
-    url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
-    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-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),
+    path(r'tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
+    path(r'tenant-groups/add/', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'),
+    path(r'tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
+    path(r'tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
+    path(r'tenant-groups/<slug:slug>/edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
+    path(r'tenant-groups/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),
 
 
     # Tenants
     # Tenants
-    url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'),
-    url(r'^tenants/add/$', views.TenantCreateView.as_view(), name='tenant_add'),
-    url(r'^tenants/import/$', views.TenantBulkImportView.as_view(), name='tenant_import'),
-    url(r'^tenants/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
-    url(r'^tenants/delete/$', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),
-    url(r'^tenants/(?P<slug>[\w-]+)/$', views.TenantView.as_view(), name='tenant'),
-    url(r'^tenants/(?P<slug>[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'),
-    url(r'^tenants/(?P<slug>[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'),
-    url(r'^tenants/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
+    path(r'tenants/', views.TenantListView.as_view(), name='tenant_list'),
+    path(r'tenants/add/', views.TenantCreateView.as_view(), name='tenant_add'),
+    path(r'tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'),
+    path(r'tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
+    path(r'tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),
+    path(r'tenants/<slug:slug>/', views.TenantView.as_view(), name='tenant'),
+    path(r'tenants/<slug:slug>/edit/', views.TenantEditView.as_view(), name='tenant_edit'),
+    path(r'tenants/<slug:slug>/delete/', views.TenantDeleteView.as_view(), name='tenant_delete'),
+    path(r'tenants/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
 
 
 ]
 ]

+ 10 - 10
netbox/users/urls.py

@@ -1,18 +1,18 @@
-from django.conf.urls import url
+from django.urls import path
 
 
 from . import views
 from . import views
 
 
 app_name = 'user'
 app_name = 'user'
 urlpatterns = [
 urlpatterns = [
 
 
-    url(r'^profile/$', views.ProfileView.as_view(), name='profile'),
-    url(r'^password/$', views.ChangePasswordView.as_view(), name='change_password'),
-    url(r'^api-tokens/$', views.TokenListView.as_view(), name='token_list'),
-    url(r'^api-tokens/add/$', views.TokenEditView.as_view(), name='token_add'),
-    url(r'^api-tokens/(?P<pk>\d+)/edit/$', views.TokenEditView.as_view(), name='token_edit'),
-    url(r'^api-tokens/(?P<pk>\d+)/delete/$', views.TokenDeleteView.as_view(), name='token_delete'),
-    url(r'^user-key/$', views.UserKeyView.as_view(), name='userkey'),
-    url(r'^user-key/edit/$', views.UserKeyEditView.as_view(), name='userkey_edit'),
-    url(r'^session-key/delete/$', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'),
+    path(r'profile/', views.ProfileView.as_view(), name='profile'),
+    path(r'password/', views.ChangePasswordView.as_view(), name='change_password'),
+    path(r'api-tokens/', views.TokenListView.as_view(), name='token_list'),
+    path(r'api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
+    path(r'api-tokens/<int:pk>/edit/', views.TokenEditView.as_view(), name='token_edit'),
+    path(r'api-tokens/<int:pk>/delete/', views.TokenDeleteView.as_view(), name='token_delete'),
+    path(r'user-key/', views.UserKeyView.as_view(), name='userkey'),
+    path(r'user-key/edit/', views.UserKeyEditView.as_view(), name='userkey_edit'),
+    path(r'session-key/delete/', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'),
 
 
 ]
 ]

+ 14 - 1
netbox/utilities/api.py

@@ -4,7 +4,7 @@ import pytz
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
 from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
-from django.db.models import ManyToManyField
+from django.db.models import ManyToManyField, ProtectedError
 from django.http import Http404
 from django.http import Http404
 from rest_framework.exceptions import APIException
 from rest_framework.exceptions import APIException
 from rest_framework.permissions import BasePermission
 from rest_framework.permissions import BasePermission
@@ -274,6 +274,19 @@ class ModelViewSet(_ModelViewSet):
         # Fall back to the hard-coded serializer class
         # Fall back to the hard-coded serializer class
         return self.serializer_class
         return self.serializer_class
 
 
+    def dispatch(self, request, *args, **kwargs):
+        try:
+            return super().dispatch(request, *args, **kwargs)
+        except ProtectedError as e:
+            models = ['{} ({})'.format(o, o._meta) for o in e.protected_objects.all()]
+            msg = 'Unable to delete object. The following dependent objects were found: {}'.format(', '.join(models))
+            return self.finalize_response(
+                request,
+                Response({'detail': msg}, status=409),
+                *args,
+                **kwargs
+            )
+
     def list(self, *args, **kwargs):
     def list(self, *args, **kwargs):
         """
         """
         Call to super to allow for caching
         Call to super to allow for caching

+ 7 - 1
netbox/utilities/middleware.py

@@ -2,6 +2,7 @@ from django.conf import settings
 from django.db import ProgrammingError
 from django.db import ProgrammingError
 from django.http import Http404, HttpResponseRedirect
 from django.http import Http404, HttpResponseRedirect
 from django.urls import reverse
 from django.urls import reverse
+import urllib
 
 
 from .views import server_error
 from .views import server_error
 
 
@@ -22,7 +23,12 @@ class LoginRequiredMiddleware(object):
             # performs its own authentication. Also metrics can be read without login.
             # performs its own authentication. Also metrics can be read without login.
             api_path = reverse('api-root')
             api_path = reverse('api-root')
             if not request.path_info.startswith((api_path, '/metrics')) and request.path_info != settings.LOGIN_URL:
             if not request.path_info.startswith((api_path, '/metrics')) and request.path_info != settings.LOGIN_URL:
-                return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, request.path_info))
+                return HttpResponseRedirect(
+                    '{}?next={}'.format(
+                        settings.LOGIN_URL,
+                        urllib.parse.quote(request.get_full_path_info())
+                    )
+                )
         return self.get_response(request)
         return self.get_response(request)
 
 
 
 

+ 9 - 23
netbox/utilities/views.py

@@ -1,5 +1,4 @@
 import sys
 import sys
-from collections import OrderedDict
 from copy import deepcopy
 from copy import deepcopy
 
 
 from django.conf import settings
 from django.conf import settings
@@ -12,7 +11,7 @@ from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHidd
 from django.http import HttpResponse, HttpResponseServerError
 from django.http import HttpResponse, HttpResponseServerError
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import loader
 from django.template import loader
-from django.template.exceptions import TemplateDoesNotExist, TemplateSyntaxError
+from django.template.exceptions import TemplateDoesNotExist
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.http import is_safe_url
 from django.utils.http import is_safe_url
@@ -23,6 +22,7 @@ from django.views.generic import View
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
 
 
 from extras.models import CustomField, CustomFieldValue, ExportTemplate
 from extras.models import CustomField, CustomFieldValue, ExportTemplate
+from extras.querysets import CustomFieldQueryset
 from utilities.forms import BootstrapMixin, CSVDataField
 from utilities.forms import BootstrapMixin, CSVDataField
 from utilities.utils import csv_format
 from utilities.utils import csv_format
 from .error_handlers import handle_protectederror
 from .error_handlers import handle_protectederror
@@ -30,23 +30,6 @@ from .forms import ConfirmationForm
 from .paginator import EnhancedPaginator
 from .paginator import EnhancedPaginator
 
 
 
 
-class CustomFieldQueryset:
-    """
-    Annotate custom fields on objects within a QuerySet.
-    """
-
-    def __init__(self, queryset, custom_fields):
-        self.queryset = queryset
-        self.model = queryset.model
-        self.custom_fields = custom_fields
-
-    def __iter__(self):
-        for obj in self.queryset:
-            values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()}
-            obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields])
-            yield obj
-
-
 class GetReturnURLMixin(object):
 class GetReturnURLMixin(object):
     """
     """
     Provides logic for determining where a user should be redirected after processing a form.
     Provides logic for determining where a user should be redirected after processing a form.
@@ -115,8 +98,9 @@ class ObjectListView(View):
             self.queryset = self.filter(request.GET, self.queryset).qs
             self.queryset = self.filter(request.GET, self.queryset).qs
 
 
         # If this type of object has one or more custom fields, prefetch any relevant custom field values
         # If this type of object has one or more custom fields, prefetch any relevant custom field values
-        custom_fields = CustomField.objects.filter(obj_type=ContentType.objects.get_for_model(model))\
-            .prefetch_related('choices')
+        custom_fields = CustomField.objects.filter(
+            obj_type=ContentType.objects.get_for_model(model)
+        ).prefetch_related('choices')
         if custom_fields:
         if custom_fields:
             self.queryset = self.queryset.prefetch_related('custom_field_values')
             self.queryset = self.queryset.prefetch_related('custom_field_values')
 
 
@@ -126,10 +110,12 @@ class ObjectListView(View):
             queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset
             queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset
             try:
             try:
                 return et.render_to_response(queryset)
                 return et.render_to_response(queryset)
-            except TemplateSyntaxError:
+            except Exception as e:
                 messages.error(
                 messages.error(
                     request,
                     request,
-                    "There was an error rendering the selected export template ({}).".format(et.name)
+                    "There was an error rendering the selected export template ({}): {}".format(
+                        et.name, e
+                    )
                 )
                 )
 
 
         # Fall back to built-in CSV formatting if export requested but no template specified
         # Fall back to built-in CSV formatting if export requested but no template specified

+ 2 - 12
netbox/virtualization/filters.py

@@ -5,7 +5,7 @@ from netaddr.core import AddrFormatError
 
 
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
-from tenancy.models import Tenant
+from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
 from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
 from .constants import VM_STATUS_CHOICES
 from .constants import VM_STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -79,7 +79,7 @@ class ClusterFilter(CustomFieldFilterSet):
         )
         )
 
 
 
 
-class VirtualMachineFilter(CustomFieldFilterSet):
+class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -150,16 +150,6 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
-    tenant_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Tenant.objects.all(),
-        label='Tenant (ID)',
-    )
-    tenant = django_filters.ModelMultipleChoiceFilter(
-        field_name='tenant__slug',
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        label='Tenant (slug)',
-    )
     platform_id = django_filters.ModelMultipleChoiceFilter(
     platform_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         label='Platform (ID)',
         label='Platform (ID)',

+ 8 - 13
netbox/virtualization/forms.py

@@ -8,6 +8,7 @@ from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, S
 from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
 from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
 from ipam.models import IPAddress
 from ipam.models import IPAddress
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
+from tenancy.forms import TenancyFilterForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
@@ -336,8 +337,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         fields = [
-            'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
-            'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
+            'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
+            'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
         ]
         ]
         help_texts = {
         help_texts = {
             'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
             'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
@@ -520,8 +521,12 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
         ]
         ]
 
 
 
 
-class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
+class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = VirtualMachine
     model = VirtualMachine
+    field_order = [
+        'q', 'cluster_group', 'cluster_type', 'cluster_id', 'status', 'role', 'region', 'site', 'tenant_group',
+        'tenant', 'platform',
+    ]
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,
         label='Search'
         label='Search'
@@ -591,16 +596,6 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
-    tenant = FilterChoiceField(
-        queryset=Tenant.objects.all(),
-        to_field_name='slug',
-        null_label='-- None --',
-        widget=APISelectMultiple(
-            api_url='/api/tenancy/tenants/',
-            value_field="slug",
-            null_option=True,
-        )
-    )
     platform = FilterChoiceField(
     platform = FilterChoiceField(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         to_field_name='slug',
         to_field_name='slug',

+ 41 - 41
netbox/virtualization/urls.py

@@ -1,4 +1,4 @@
-from django.conf.urls import url
+from django.urls import path
 
 
 from extras.views import ObjectChangeLogView
 from extras.views import ObjectChangeLogView
 from ipam.views import ServiceCreateView
 from ipam.views import ServiceCreateView
@@ -9,53 +9,53 @@ app_name = 'virtualization'
 urlpatterns = [
 urlpatterns = [
 
 
     # Cluster types
     # Cluster types
-    url(r'^cluster-types/$', views.ClusterTypeListView.as_view(), name='clustertype_list'),
-    url(r'^cluster-types/add/$', views.ClusterTypeCreateView.as_view(), name='clustertype_add'),
-    url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
-    url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
-    url(r'^cluster-types/(?P<slug>[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
-    url(r'^cluster-types/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
+    path(r'cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'),
+    path(r'cluster-types/add/', views.ClusterTypeCreateView.as_view(), name='clustertype_add'),
+    path(r'cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
+    path(r'cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
+    path(r'cluster-types/<slug:slug>/edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
+    path(r'cluster-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
 
 
     # Cluster groups
     # Cluster groups
-    url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
-    url(r'^cluster-groups/add/$', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'),
-    url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
-    url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
-    url(r'^cluster-groups/(?P<slug>[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
-    url(r'^cluster-groups/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
+    path(r'cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
+    path(r'cluster-groups/add/', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'),
+    path(r'cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
+    path(r'cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
+    path(r'cluster-groups/<slug:slug>/edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
+    path(r'cluster-groups/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
 
 
     # Clusters
     # Clusters
-    url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'),
-    url(r'^clusters/add/$', views.ClusterCreateView.as_view(), name='cluster_add'),
-    url(r'^clusters/import/$', views.ClusterBulkImportView.as_view(), name='cluster_import'),
-    url(r'^clusters/edit/$', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
-    url(r'^clusters/delete/$', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
-    url(r'^clusters/(?P<pk>\d+)/$', views.ClusterView.as_view(), name='cluster'),
-    url(r'^clusters/(?P<pk>\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'),
-    url(r'^clusters/(?P<pk>\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'),
-    url(r'^clusters/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
-    url(r'^clusters/(?P<pk>\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
-    url(r'^clusters/(?P<pk>\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
+    path(r'clusters/', views.ClusterListView.as_view(), name='cluster_list'),
+    path(r'clusters/add/', views.ClusterCreateView.as_view(), name='cluster_add'),
+    path(r'clusters/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'),
+    path(r'clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
+    path(r'clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
+    path(r'clusters/<int:pk>/', views.ClusterView.as_view(), name='cluster'),
+    path(r'clusters/<int:pk>/edit/', views.ClusterEditView.as_view(), name='cluster_edit'),
+    path(r'clusters/<int:pk>/delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'),
+    path(r'clusters/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
+    path(r'clusters/<int:pk>/devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
+    path(r'clusters/<int:pk>/devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
 
 
     # Virtual machines
     # Virtual machines
-    url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
-    url(r'^virtual-machines/add/$', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'),
-    url(r'^virtual-machines/import/$', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
-    url(r'^virtual-machines/edit/$', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
-    url(r'^virtual-machines/delete/$', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
-    url(r'^virtual-machines/(?P<pk>\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'),
-    url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
-    url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
-    url(r'^virtual-machines/(?P<pk>\d+)/config-context/$', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
-    url(r'^virtual-machines/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
-    url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
+    path(r'virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
+    path(r'virtual-machines/add/', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'),
+    path(r'virtual-machines/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
+    path(r'virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
+    path(r'virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
+    path(r'virtual-machines/<int:pk>/', views.VirtualMachineView.as_view(), name='virtualmachine'),
+    path(r'virtual-machines/<int:pk>/edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
+    path(r'virtual-machines/<int:pk>/delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
+    path(r'virtual-machines/<int:pk>/config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
+    path(r'virtual-machines/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
+    path(r'virtual-machines/<int:virtualmachine>/services/assign/', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
 
 
     # VM interfaces
     # VM interfaces
-    url(r'^virtual-machines/interfaces/add/$', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'),
-    url(r'^virtual-machines/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
-    url(r'^virtual-machines/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
-    url(r'^virtual-machines/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    url(r'^vm-interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
-    url(r'^vm-interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
+    path(r'virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'),
+    path(r'virtual-machines/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
+    path(r'virtual-machines/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
+    path(r'virtual-machines/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
+    path(r'vm-interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
+    path(r'vm-interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
 
 
 ]
 ]

+ 5 - 0
requirements.txt

@@ -10,8 +10,13 @@ django-tables2==2.0.6
 django-taggit==1.1.0
 django-taggit==1.1.0
 django-taggit-serializer==0.1.7
 django-taggit-serializer==0.1.7
 django-timezone-field==3.0
 django-timezone-field==3.0
+<<<<<<< HEAD
 djangorestframework==3.9.2
 djangorestframework==3.9.2
 drf-yasg[validation]==1.15.0
 drf-yasg[validation]==1.15.0
+=======
+djangorestframework==3.9.1
+drf-yasg[validation]==1.14.0
+>>>>>>> develop
 graphviz==0.10.1
 graphviz==0.10.1
 Jinja2==2.10.1
 Jinja2==2.10.1
 Markdown==2.6.11
 Markdown==2.6.11

+ 2 - 0
upgrade.sh

@@ -5,6 +5,8 @@
 # Once the script completes, remember to restart the WSGI service (e.g.
 # Once the script completes, remember to restart the WSGI service (e.g.
 # gunicorn or uWSGI).
 # gunicorn or uWSGI).
 
 
+cd "$(dirname "$0")"
+
 PYTHON="python3"
 PYTHON="python3"
 PIP="pip3"
 PIP="pip3"