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

Relocate and group generic views

Jeremy Stretch 5 лет назад
Родитель
Сommit
b3c770216e

+ 23 - 25
netbox/circuits/views.py

@@ -4,11 +4,9 @@ from django.db.models import Count
 from django.shortcuts import get_object_or_404, redirect, render
 from django_tables2 import RequestConfig
 
+from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
-from utilities.views import (
-    BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
-)
 from . import filters, forms, tables
 from .choices import CircuitTerminationSideChoices
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -18,14 +16,14 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
 # Providers
 #
 
-class ProviderListView(ObjectListView):
+class ProviderListView(generic.ObjectListView):
     queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
     filterset = filters.ProviderFilterSet
     filterset_form = forms.ProviderFilterForm
     table = tables.ProviderTable
 
 
-class ProviderView(ObjectView):
+class ProviderView(generic.ObjectView):
     queryset = Provider.objects.all()
 
     def get(self, request, slug):
@@ -52,30 +50,30 @@ class ProviderView(ObjectView):
         })
 
 
-class ProviderEditView(ObjectEditView):
+class ProviderEditView(generic.ObjectEditView):
     queryset = Provider.objects.all()
     model_form = forms.ProviderForm
     template_name = 'circuits/provider_edit.html'
 
 
-class ProviderDeleteView(ObjectDeleteView):
+class ProviderDeleteView(generic.ObjectDeleteView):
     queryset = Provider.objects.all()
 
 
-class ProviderBulkImportView(BulkImportView):
+class ProviderBulkImportView(generic.BulkImportView):
     queryset = Provider.objects.all()
     model_form = forms.ProviderCSVForm
     table = tables.ProviderTable
 
 
-class ProviderBulkEditView(BulkEditView):
+class ProviderBulkEditView(generic.BulkEditView):
     queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
     filterset = filters.ProviderFilterSet
     table = tables.ProviderTable
     form = forms.ProviderBulkEditForm
 
 
-class ProviderBulkDeleteView(BulkDeleteView):
+class ProviderBulkDeleteView(generic.BulkDeleteView):
     queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
     filterset = filters.ProviderFilterSet
     table = tables.ProviderTable
@@ -85,27 +83,27 @@ class ProviderBulkDeleteView(BulkDeleteView):
 # Circuit Types
 #
 
-class CircuitTypeListView(ObjectListView):
+class CircuitTypeListView(generic.ObjectListView):
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
     table = tables.CircuitTypeTable
 
 
-class CircuitTypeEditView(ObjectEditView):
+class CircuitTypeEditView(generic.ObjectEditView):
     queryset = CircuitType.objects.all()
     model_form = forms.CircuitTypeForm
 
 
-class CircuitTypeDeleteView(ObjectDeleteView):
+class CircuitTypeDeleteView(generic.ObjectDeleteView):
     queryset = CircuitType.objects.all()
 
 
-class CircuitTypeBulkImportView(BulkImportView):
+class CircuitTypeBulkImportView(generic.BulkImportView):
     queryset = CircuitType.objects.all()
     model_form = forms.CircuitTypeCSVForm
     table = tables.CircuitTypeTable
 
 
-class CircuitTypeBulkDeleteView(BulkDeleteView):
+class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
     table = tables.CircuitTypeTable
 
@@ -114,7 +112,7 @@ class CircuitTypeBulkDeleteView(BulkDeleteView):
 # Circuits
 #
 
-class CircuitListView(ObjectListView):
+class CircuitListView(generic.ObjectListView):
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'terminations'
     ).annotate_sites()
@@ -123,7 +121,7 @@ class CircuitListView(ObjectListView):
     table = tables.CircuitTable
 
 
-class CircuitView(ObjectView):
+class CircuitView(generic.ObjectView):
     queryset = Circuit.objects.all()
 
     def get(self, request, pk):
@@ -152,23 +150,23 @@ class CircuitView(ObjectView):
         })
 
 
-class CircuitEditView(ObjectEditView):
+class CircuitEditView(generic.ObjectEditView):
     queryset = Circuit.objects.all()
     model_form = forms.CircuitForm
     template_name = 'circuits/circuit_edit.html'
 
 
-class CircuitDeleteView(ObjectDeleteView):
+class CircuitDeleteView(generic.ObjectDeleteView):
     queryset = Circuit.objects.all()
 
 
-class CircuitBulkImportView(BulkImportView):
+class CircuitBulkImportView(generic.BulkImportView):
     queryset = Circuit.objects.all()
     model_form = forms.CircuitCSVForm
     table = tables.CircuitTable
 
 
-class CircuitBulkEditView(BulkEditView):
+class CircuitBulkEditView(generic.BulkEditView):
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'terminations'
     )
@@ -177,7 +175,7 @@ class CircuitBulkEditView(BulkEditView):
     form = forms.CircuitBulkEditForm
 
 
-class CircuitBulkDeleteView(BulkDeleteView):
+class CircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = Circuit.objects.prefetch_related(
         'provider', 'type', 'tenant', 'terminations'
     )
@@ -185,7 +183,7 @@ class CircuitBulkDeleteView(BulkDeleteView):
     table = tables.CircuitTable
 
 
-class CircuitSwapTerminations(ObjectEditView):
+class CircuitSwapTerminations(generic.ObjectEditView):
     """
     Swap the A and Z terminations of a circuit.
     """
@@ -258,7 +256,7 @@ class CircuitSwapTerminations(ObjectEditView):
 # Circuit terminations
 #
 
-class CircuitTerminationEditView(ObjectEditView):
+class CircuitTerminationEditView(generic.ObjectEditView):
     queryset = CircuitTermination.objects.all()
     model_form = forms.CircuitTerminationForm
     template_name = 'circuits/circuittermination_edit.html'
@@ -272,5 +270,5 @@ class CircuitTerminationEditView(ObjectEditView):
         return obj.circuit.get_absolute_url()
 
 
-class CircuitTerminationDeleteView(ObjectDeleteView):
+class CircuitTerminationDeleteView(generic.ObjectDeleteView):
     queryset = CircuitTermination.objects.all()

Разница между файлами не показана из-за своего большого размера
+ 137 - 141
netbox/dcim/views.py


+ 19 - 21
netbox/extras/views.py

@@ -10,14 +10,12 @@ from django_tables2 import RequestConfig
 from rq import Worker
 
 from dcim.models import DeviceRole, Platform, Region, Site
+from netbox.views import generic
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.utils import copy_safe_request, shallow_compare_dict
-from utilities.views import (
-    BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
-    ContentTypePermissionRequiredMixin,
-)
+from utilities.views import ContentTypePermissionRequiredMixin
 from virtualization.models import Cluster, ClusterGroup
 from . import filters, forms, tables
 from .choices import JobResultStatusChoices
@@ -30,7 +28,7 @@ from .scripts import get_scripts, run_script
 # Tags
 #
 
-class TagListView(ObjectListView):
+class TagListView(generic.ObjectListView):
     queryset = Tag.objects.annotate(
         items=Count('extras_taggeditem_items')
     ).order_by(*Tag._meta.ordering)
@@ -39,23 +37,23 @@ class TagListView(ObjectListView):
     table = tables.TagTable
 
 
-class TagEditView(ObjectEditView):
+class TagEditView(generic.ObjectEditView):
     queryset = Tag.objects.all()
     model_form = forms.TagForm
     template_name = 'extras/tag_edit.html'
 
 
-class TagDeleteView(ObjectDeleteView):
+class TagDeleteView(generic.ObjectDeleteView):
     queryset = Tag.objects.all()
 
 
-class TagBulkImportView(BulkImportView):
+class TagBulkImportView(generic.BulkImportView):
     queryset = Tag.objects.all()
     model_form = forms.TagCSVForm
     table = tables.TagTable
 
 
-class TagBulkEditView(BulkEditView):
+class TagBulkEditView(generic.BulkEditView):
     queryset = Tag.objects.annotate(
         items=Count('extras_taggeditem_items')
     ).order_by(*Tag._meta.ordering)
@@ -63,7 +61,7 @@ class TagBulkEditView(BulkEditView):
     form = forms.TagBulkEditForm
 
 
-class TagBulkDeleteView(BulkDeleteView):
+class TagBulkDeleteView(generic.BulkDeleteView):
     queryset = Tag.objects.annotate(
         items=Count('extras_taggeditem_items')
     ).order_by(*Tag._meta.ordering)
@@ -74,7 +72,7 @@ class TagBulkDeleteView(BulkDeleteView):
 # Config contexts
 #
 
-class ConfigContextListView(ObjectListView):
+class ConfigContextListView(generic.ObjectListView):
     queryset = ConfigContext.objects.all()
     filterset = filters.ConfigContextFilterSet
     filterset_form = forms.ConfigContextFilterForm
@@ -82,7 +80,7 @@ class ConfigContextListView(ObjectListView):
     action_buttons = ('add',)
 
 
-class ConfigContextView(ObjectView):
+class ConfigContextView(generic.ObjectView):
     queryset = ConfigContext.objects.all()
 
     def get(self, request, pk):
@@ -116,29 +114,29 @@ class ConfigContextView(ObjectView):
         })
 
 
-class ConfigContextEditView(ObjectEditView):
+class ConfigContextEditView(generic.ObjectEditView):
     queryset = ConfigContext.objects.all()
     model_form = forms.ConfigContextForm
     template_name = 'extras/configcontext_edit.html'
 
 
-class ConfigContextBulkEditView(BulkEditView):
+class ConfigContextBulkEditView(generic.BulkEditView):
     queryset = ConfigContext.objects.all()
     filterset = filters.ConfigContextFilterSet
     table = tables.ConfigContextTable
     form = forms.ConfigContextBulkEditForm
 
 
-class ConfigContextDeleteView(ObjectDeleteView):
+class ConfigContextDeleteView(generic.ObjectDeleteView):
     queryset = ConfigContext.objects.all()
 
 
-class ConfigContextBulkDeleteView(BulkDeleteView):
+class ConfigContextBulkDeleteView(generic.BulkDeleteView):
     queryset = ConfigContext.objects.all()
     table = tables.ConfigContextTable
 
 
-class ObjectConfigContextView(ObjectView):
+class ObjectConfigContextView(generic.ObjectView):
     base_template = None
 
     def get(self, request, pk):
@@ -172,7 +170,7 @@ class ObjectConfigContextView(ObjectView):
 # Change logging
 #
 
-class ObjectChangeListView(ObjectListView):
+class ObjectChangeListView(generic.ObjectListView):
     queryset = ObjectChange.objects.all()
     filterset = filters.ObjectChangeFilterSet
     filterset_form = forms.ObjectChangeFilterForm
@@ -181,7 +179,7 @@ class ObjectChangeListView(ObjectListView):
     action_buttons = ('export',)
 
 
-class ObjectChangeView(ObjectView):
+class ObjectChangeView(generic.ObjectView):
     queryset = ObjectChange.objects.all()
 
     def get(self, request, pk):
@@ -283,7 +281,7 @@ class ObjectChangeLogView(View):
 # Image attachments
 #
 
-class ImageAttachmentEditView(ObjectEditView):
+class ImageAttachmentEditView(generic.ObjectEditView):
     queryset = ImageAttachment.objects.all()
     model_form = forms.ImageAttachmentForm
 
@@ -298,7 +296,7 @@ class ImageAttachmentEditView(ObjectEditView):
         return imageattachment.parent.get_absolute_url()
 
 
-class ImageAttachmentDeleteView(ObjectDeleteView):
+class ImageAttachmentDeleteView(generic.ObjectDeleteView):
     queryset = ImageAttachment.objects.all()
 
     def get_return_url(self, request, imageattachment):

+ 72 - 78
netbox/ipam/views.py

@@ -1,20 +1,14 @@
-import netaddr
-from django.conf import settings
 from django.db.models import Count, Prefetch
 from django.db.models.expressions import RawSQL
 from django.shortcuts import get_object_or_404, redirect, render
 from django_tables2 import RequestConfig
 
 from dcim.models import Device, Interface
+from netbox.views import generic
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.utils import get_subquery
-from utilities.views import (
-    BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
-    ObjectListView,
-)
 from virtualization.models import VirtualMachine, VMInterface
 from . import filters, forms, tables
-from .choices import *
 from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
@@ -24,14 +18,14 @@ from .utils import add_available_ipaddresses, add_available_prefixes, add_availa
 # VRFs
 #
 
-class VRFListView(ObjectListView):
+class VRFListView(generic.ObjectListView):
     queryset = VRF.objects.all()
     filterset = filters.VRFFilterSet
     filterset_form = forms.VRFFilterForm
     table = tables.VRFTable
 
 
-class VRFView(ObjectView):
+class VRFView(generic.ObjectView):
     queryset = VRF.objects.all()
 
     def get(self, request, pk):
@@ -56,30 +50,30 @@ class VRFView(ObjectView):
         })
 
 
-class VRFEditView(ObjectEditView):
+class VRFEditView(generic.ObjectEditView):
     queryset = VRF.objects.all()
     model_form = forms.VRFForm
     template_name = 'ipam/vrf_edit.html'
 
 
-class VRFDeleteView(ObjectDeleteView):
+class VRFDeleteView(generic.ObjectDeleteView):
     queryset = VRF.objects.all()
 
 
-class VRFBulkImportView(BulkImportView):
+class VRFBulkImportView(generic.BulkImportView):
     queryset = VRF.objects.all()
     model_form = forms.VRFCSVForm
     table = tables.VRFTable
 
 
-class VRFBulkEditView(BulkEditView):
+class VRFBulkEditView(generic.BulkEditView):
     queryset = VRF.objects.prefetch_related('tenant')
     filterset = filters.VRFFilterSet
     table = tables.VRFTable
     form = forms.VRFBulkEditForm
 
 
-class VRFBulkDeleteView(BulkDeleteView):
+class VRFBulkDeleteView(generic.BulkDeleteView):
     queryset = VRF.objects.prefetch_related('tenant')
     filterset = filters.VRFFilterSet
     table = tables.VRFTable
@@ -89,14 +83,14 @@ class VRFBulkDeleteView(BulkDeleteView):
 # Route targets
 #
 
-class RouteTargetListView(ObjectListView):
+class RouteTargetListView(generic.ObjectListView):
     queryset = RouteTarget.objects.all()
     filterset = filters.RouteTargetFilterSet
     filterset_form = forms.RouteTargetFilterForm
     table = tables.RouteTargetTable
 
 
-class RouteTargetView(ObjectView):
+class RouteTargetView(generic.ObjectView):
     queryset = RouteTarget.objects.all()
 
     def get(self, request, pk):
@@ -118,29 +112,29 @@ class RouteTargetView(ObjectView):
         })
 
 
-class RouteTargetEditView(ObjectEditView):
+class RouteTargetEditView(generic.ObjectEditView):
     queryset = RouteTarget.objects.all()
     model_form = forms.RouteTargetForm
 
 
-class RouteTargetDeleteView(ObjectDeleteView):
+class RouteTargetDeleteView(generic.ObjectDeleteView):
     queryset = RouteTarget.objects.all()
 
 
-class RouteTargetBulkImportView(BulkImportView):
+class RouteTargetBulkImportView(generic.BulkImportView):
     queryset = RouteTarget.objects.all()
     model_form = forms.RouteTargetCSVForm
     table = tables.RouteTargetTable
 
 
-class RouteTargetBulkEditView(BulkEditView):
+class RouteTargetBulkEditView(generic.BulkEditView):
     queryset = RouteTarget.objects.prefetch_related('tenant')
     filterset = filters.RouteTargetFilterSet
     table = tables.RouteTargetTable
     form = forms.RouteTargetBulkEditForm
 
 
-class RouteTargetBulkDeleteView(BulkDeleteView):
+class RouteTargetBulkDeleteView(generic.BulkDeleteView):
     queryset = RouteTarget.objects.prefetch_related('tenant')
     filterset = filters.RouteTargetFilterSet
     table = tables.RouteTargetTable
@@ -150,7 +144,7 @@ class RouteTargetBulkDeleteView(BulkDeleteView):
 # RIRs
 #
 
-class RIRListView(ObjectListView):
+class RIRListView(generic.ObjectListView):
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')).order_by(*RIR._meta.ordering)
     filterset = filters.RIRFilterSet
     filterset_form = forms.RIRFilterForm
@@ -158,22 +152,22 @@ class RIRListView(ObjectListView):
     template_name = 'ipam/rir_list.html'
 
 
-class RIREditView(ObjectEditView):
+class RIREditView(generic.ObjectEditView):
     queryset = RIR.objects.all()
     model_form = forms.RIRForm
 
 
-class RIRDeleteView(ObjectDeleteView):
+class RIRDeleteView(generic.ObjectDeleteView):
     queryset = RIR.objects.all()
 
 
-class RIRBulkImportView(BulkImportView):
+class RIRBulkImportView(generic.BulkImportView):
     queryset = RIR.objects.all()
     model_form = forms.RIRCSVForm
     table = tables.RIRTable
 
 
-class RIRBulkDeleteView(BulkDeleteView):
+class RIRBulkDeleteView(generic.BulkDeleteView):
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')).order_by(*RIR._meta.ordering)
     filterset = filters.RIRFilterSet
     table = tables.RIRTable
@@ -183,7 +177,7 @@ class RIRBulkDeleteView(BulkDeleteView):
 # Aggregates
 #
 
-class AggregateListView(ObjectListView):
+class AggregateListView(generic.ObjectListView):
     queryset = Aggregate.objects.annotate(
         child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
     ).order_by(*Aggregate._meta.ordering)
@@ -209,7 +203,7 @@ class AggregateListView(ObjectListView):
         }
 
 
-class AggregateView(ObjectView):
+class AggregateView(generic.ObjectView):
     queryset = Aggregate.objects.all()
 
     def get(self, request, pk):
@@ -254,30 +248,30 @@ class AggregateView(ObjectView):
         })
 
 
-class AggregateEditView(ObjectEditView):
+class AggregateEditView(generic.ObjectEditView):
     queryset = Aggregate.objects.all()
     model_form = forms.AggregateForm
     template_name = 'ipam/aggregate_edit.html'
 
 
-class AggregateDeleteView(ObjectDeleteView):
+class AggregateDeleteView(generic.ObjectDeleteView):
     queryset = Aggregate.objects.all()
 
 
-class AggregateBulkImportView(BulkImportView):
+class AggregateBulkImportView(generic.BulkImportView):
     queryset = Aggregate.objects.all()
     model_form = forms.AggregateCSVForm
     table = tables.AggregateTable
 
 
-class AggregateBulkEditView(BulkEditView):
+class AggregateBulkEditView(generic.BulkEditView):
     queryset = Aggregate.objects.prefetch_related('rir')
     filterset = filters.AggregateFilterSet
     table = tables.AggregateTable
     form = forms.AggregateBulkEditForm
 
 
-class AggregateBulkDeleteView(BulkDeleteView):
+class AggregateBulkDeleteView(generic.BulkDeleteView):
     queryset = Aggregate.objects.prefetch_related('rir')
     filterset = filters.AggregateFilterSet
     table = tables.AggregateTable
@@ -287,7 +281,7 @@ class AggregateBulkDeleteView(BulkDeleteView):
 # Prefix/VLAN roles
 #
 
-class RoleListView(ObjectListView):
+class RoleListView(generic.ObjectListView):
     queryset = Role.objects.annotate(
         prefix_count=get_subquery(Prefix, 'role'),
         vlan_count=get_subquery(VLAN, 'role')
@@ -295,22 +289,22 @@ class RoleListView(ObjectListView):
     table = tables.RoleTable
 
 
-class RoleEditView(ObjectEditView):
+class RoleEditView(generic.ObjectEditView):
     queryset = Role.objects.all()
     model_form = forms.RoleForm
 
 
-class RoleDeleteView(ObjectDeleteView):
+class RoleDeleteView(generic.ObjectDeleteView):
     queryset = Role.objects.all()
 
 
-class RoleBulkImportView(BulkImportView):
+class RoleBulkImportView(generic.BulkImportView):
     queryset = Role.objects.all()
     model_form = forms.RoleCSVForm
     table = tables.RoleTable
 
 
-class RoleBulkDeleteView(BulkDeleteView):
+class RoleBulkDeleteView(generic.BulkDeleteView):
     queryset = Role.objects.all()
     table = tables.RoleTable
 
@@ -319,7 +313,7 @@ class RoleBulkDeleteView(BulkDeleteView):
 # Prefixes
 #
 
-class PrefixListView(ObjectListView):
+class PrefixListView(generic.ObjectListView):
     queryset = Prefix.objects.annotate_tree()
     filterset = filters.PrefixFilterSet
     filterset_form = forms.PrefixFilterForm
@@ -327,7 +321,7 @@ class PrefixListView(ObjectListView):
     template_name = 'ipam/prefix_list.html'
 
 
-class PrefixView(ObjectView):
+class PrefixView(generic.ObjectView):
     queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role')
 
     def get(self, request, pk):
@@ -371,7 +365,7 @@ class PrefixView(ObjectView):
         })
 
 
-class PrefixPrefixesView(ObjectView):
+class PrefixPrefixesView(generic.ObjectView):
     queryset = Prefix.objects.all()
 
     def get(self, request, pk):
@@ -415,7 +409,7 @@ class PrefixPrefixesView(ObjectView):
         })
 
 
-class PrefixIPAddressesView(ObjectView):
+class PrefixIPAddressesView(generic.ObjectView):
     queryset = Prefix.objects.all()
 
     def get(self, request, pk):
@@ -459,31 +453,31 @@ class PrefixIPAddressesView(ObjectView):
         })
 
 
-class PrefixEditView(ObjectEditView):
+class PrefixEditView(generic.ObjectEditView):
     queryset = Prefix.objects.all()
     model_form = forms.PrefixForm
     template_name = 'ipam/prefix_edit.html'
 
 
-class PrefixDeleteView(ObjectDeleteView):
+class PrefixDeleteView(generic.ObjectDeleteView):
     queryset = Prefix.objects.all()
     template_name = 'ipam/prefix_delete.html'
 
 
-class PrefixBulkImportView(BulkImportView):
+class PrefixBulkImportView(generic.BulkImportView):
     queryset = Prefix.objects.all()
     model_form = forms.PrefixCSVForm
     table = tables.PrefixTable
 
 
-class PrefixBulkEditView(BulkEditView):
+class PrefixBulkEditView(generic.BulkEditView):
     queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filterset = filters.PrefixFilterSet
     table = tables.PrefixTable
     form = forms.PrefixBulkEditForm
 
 
-class PrefixBulkDeleteView(BulkDeleteView):
+class PrefixBulkDeleteView(generic.BulkDeleteView):
     queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filterset = filters.PrefixFilterSet
     table = tables.PrefixTable
@@ -493,14 +487,14 @@ class PrefixBulkDeleteView(BulkDeleteView):
 # IP addresses
 #
 
-class IPAddressListView(ObjectListView):
+class IPAddressListView(generic.ObjectListView):
     queryset = IPAddress.objects.all()
     filterset = filters.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
     table = tables.IPAddressDetailTable
 
 
-class IPAddressView(ObjectView):
+class IPAddressView(generic.ObjectView):
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
 
     def get(self, request, pk):
@@ -553,7 +547,7 @@ class IPAddressView(ObjectView):
         })
 
 
-class IPAddressEditView(ObjectEditView):
+class IPAddressEditView(generic.ObjectEditView):
     queryset = IPAddress.objects.all()
     model_form = forms.IPAddressForm
     template_name = 'ipam/ipaddress_edit.html'
@@ -575,7 +569,7 @@ class IPAddressEditView(ObjectEditView):
         return obj
 
 
-class IPAddressAssignView(ObjectView):
+class IPAddressAssignView(generic.ObjectView):
     """
     Search for IPAddresses to be assigned to an Interface.
     """
@@ -615,11 +609,11 @@ class IPAddressAssignView(ObjectView):
         })
 
 
-class IPAddressDeleteView(ObjectDeleteView):
+class IPAddressDeleteView(generic.ObjectDeleteView):
     queryset = IPAddress.objects.all()
 
 
-class IPAddressBulkCreateView(BulkCreateView):
+class IPAddressBulkCreateView(generic.BulkCreateView):
     queryset = IPAddress.objects.all()
     form = forms.IPAddressBulkCreateForm
     model_form = forms.IPAddressBulkAddForm
@@ -627,20 +621,20 @@ class IPAddressBulkCreateView(BulkCreateView):
     template_name = 'ipam/ipaddress_bulk_add.html'
 
 
-class IPAddressBulkImportView(BulkImportView):
+class IPAddressBulkImportView(generic.BulkImportView):
     queryset = IPAddress.objects.all()
     model_form = forms.IPAddressCSVForm
     table = tables.IPAddressTable
 
 
-class IPAddressBulkEditView(BulkEditView):
+class IPAddressBulkEditView(generic.BulkEditView):
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
     filterset = filters.IPAddressFilterSet
     table = tables.IPAddressTable
     form = forms.IPAddressBulkEditForm
 
 
-class IPAddressBulkDeleteView(BulkDeleteView):
+class IPAddressBulkDeleteView(generic.BulkDeleteView):
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
     filterset = filters.IPAddressFilterSet
     table = tables.IPAddressTable
@@ -650,7 +644,7 @@ class IPAddressBulkDeleteView(BulkDeleteView):
 # VLAN groups
 #
 
-class VLANGroupListView(ObjectListView):
+class VLANGroupListView(generic.ObjectListView):
     queryset = VLANGroup.objects.annotate(
         vlan_count=Count('vlans')
     ).order_by(*VLANGroup._meta.ordering)
@@ -659,22 +653,22 @@ class VLANGroupListView(ObjectListView):
     table = tables.VLANGroupTable
 
 
-class VLANGroupEditView(ObjectEditView):
+class VLANGroupEditView(generic.ObjectEditView):
     queryset = VLANGroup.objects.all()
     model_form = forms.VLANGroupForm
 
 
-class VLANGroupDeleteView(ObjectDeleteView):
+class VLANGroupDeleteView(generic.ObjectDeleteView):
     queryset = VLANGroup.objects.all()
 
 
-class VLANGroupBulkImportView(BulkImportView):
+class VLANGroupBulkImportView(generic.BulkImportView):
     queryset = VLANGroup.objects.all()
     model_form = forms.VLANGroupCSVForm
     table = tables.VLANGroupTable
 
 
-class VLANGroupBulkDeleteView(BulkDeleteView):
+class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = VLANGroup.objects.prefetch_related('site').annotate(
         vlan_count=Count('vlans')
     ).order_by(*VLANGroup._meta.ordering)
@@ -682,7 +676,7 @@ class VLANGroupBulkDeleteView(BulkDeleteView):
     table = tables.VLANGroupTable
 
 
-class VLANGroupVLANsView(ObjectView):
+class VLANGroupVLANsView(generic.ObjectView):
     queryset = VLANGroup.objects.all()
 
     def get(self, request, pk):
@@ -725,14 +719,14 @@ class VLANGroupVLANsView(ObjectView):
 # VLANs
 #
 
-class VLANListView(ObjectListView):
+class VLANListView(generic.ObjectListView):
     queryset = VLAN.objects.all()
     filterset = filters.VLANFilterSet
     filterset_form = forms.VLANFilterForm
     table = tables.VLANDetailTable
 
 
-class VLANView(ObjectView):
+class VLANView(generic.ObjectView):
     queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role')
 
     def get(self, request, pk):
@@ -750,7 +744,7 @@ class VLANView(ObjectView):
         })
 
 
-class VLANInterfacesView(ObjectView):
+class VLANInterfacesView(generic.ObjectView):
     queryset = VLAN.objects.all()
 
     def get(self, request, pk):
@@ -771,7 +765,7 @@ class VLANInterfacesView(ObjectView):
         })
 
 
-class VLANVMInterfacesView(ObjectView):
+class VLANVMInterfacesView(generic.ObjectView):
     queryset = VLAN.objects.all()
 
     def get(self, request, pk):
@@ -792,30 +786,30 @@ class VLANVMInterfacesView(ObjectView):
         })
 
 
-class VLANEditView(ObjectEditView):
+class VLANEditView(generic.ObjectEditView):
     queryset = VLAN.objects.all()
     model_form = forms.VLANForm
     template_name = 'ipam/vlan_edit.html'
 
 
-class VLANDeleteView(ObjectDeleteView):
+class VLANDeleteView(generic.ObjectDeleteView):
     queryset = VLAN.objects.all()
 
 
-class VLANBulkImportView(BulkImportView):
+class VLANBulkImportView(generic.BulkImportView):
     queryset = VLAN.objects.all()
     model_form = forms.VLANCSVForm
     table = tables.VLANTable
 
 
-class VLANBulkEditView(BulkEditView):
+class VLANBulkEditView(generic.BulkEditView):
     queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
     filterset = filters.VLANFilterSet
     table = tables.VLANTable
     form = forms.VLANBulkEditForm
 
 
-class VLANBulkDeleteView(BulkDeleteView):
+class VLANBulkDeleteView(generic.BulkDeleteView):
     queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
     filterset = filters.VLANFilterSet
     table = tables.VLANTable
@@ -825,7 +819,7 @@ class VLANBulkDeleteView(BulkDeleteView):
 # Services
 #
 
-class ServiceListView(ObjectListView):
+class ServiceListView(generic.ObjectListView):
     queryset = Service.objects.all()
     filterset = filters.ServiceFilterSet
     filterset_form = forms.ServiceFilterForm
@@ -833,7 +827,7 @@ class ServiceListView(ObjectListView):
     action_buttons = ('export',)
 
 
-class ServiceView(ObjectView):
+class ServiceView(generic.ObjectView):
     queryset = Service.objects.prefetch_related('ipaddresses')
 
     def get(self, request, pk):
@@ -845,7 +839,7 @@ class ServiceView(ObjectView):
         })
 
 
-class ServiceEditView(ObjectEditView):
+class ServiceEditView(generic.ObjectEditView):
     queryset = Service.objects.prefetch_related('ipaddresses')
     model_form = forms.ServiceForm
     template_name = 'ipam/service_edit.html'
@@ -864,24 +858,24 @@ class ServiceEditView(ObjectEditView):
         return obj
 
 
-class ServiceBulkImportView(BulkImportView):
+class ServiceBulkImportView(generic.BulkImportView):
     queryset = Service.objects.all()
     model_form = forms.ServiceCSVForm
     table = tables.ServiceTable
 
 
-class ServiceDeleteView(ObjectDeleteView):
+class ServiceDeleteView(generic.ObjectDeleteView):
     queryset = Service.objects.all()
 
 
-class ServiceBulkEditView(BulkEditView):
+class ServiceBulkEditView(generic.BulkEditView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filterset = filters.ServiceFilterSet
     table = tables.ServiceTable
     form = forms.ServiceBulkEditForm
 
 
-class ServiceBulkDeleteView(BulkDeleteView):
+class ServiceBulkDeleteView(generic.BulkDeleteView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filterset = filters.ServiceFilterSet
     table = tables.ServiceTable

+ 1 - 1
netbox/netbox/middleware.py

@@ -8,8 +8,8 @@ from django.http import Http404, HttpResponseRedirect
 from django.urls import reverse
 
 from extras.context_managers import change_logging
+from netbox.views import server_error
 from utilities.api import is_api_request, rest_api_server_error
-from utilities.views import server_error
 
 
 class LoginRequiredMiddleware(object):

+ 1 - 1
netbox/netbox/urls.py

@@ -94,4 +94,4 @@ urlpatterns = [
     path('{}'.format(settings.BASE_PATH), include(_patterns))
 ]
 
-handler500 = 'utilities.views.server_error'
+handler500 = 'netbox.views.server_error'

+ 32 - 3
netbox/netbox/views.py → netbox/netbox/views/__init__.py

@@ -1,22 +1,32 @@
+import platform
+import sys
+
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import F
+from django.http import HttpResponseServerError
 from django.shortcuts import render
+from django.template import loader
+from django.template.exceptions import TemplateDoesNotExist
 from django.urls import reverse
+from django.views.decorators.csrf import requires_csrf_token
+from django.views.defaults import ERROR_500_TEMPLATE_NAME
 from django.views.generic import View
 from packaging import version
 
 from circuits.models import Circuit, Provider
-from dcim.models import Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site
+from dcim.models import (
+    Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site,
+)
 from extras.choices import JobResultStatusChoices
 from extras.models import ObjectChange, JobResult
 from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
+from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
+from netbox.forms import SearchForm
 from netbox.releases import get_latest_release
 from secrets.models import Secret
 from tenancy.models import Tenant
 from virtualization.models import Cluster, VirtualMachine
-from .constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
-from .forms import SearchForm
 
 
 class HomeView(View):
@@ -157,3 +167,22 @@ class StaticMediaFailureView(View):
         return render(request, 'media_failure.html', {
             'filename': request.GET.get('filename')
         })
+
+
+@requires_csrf_token
+def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
+    """
+    Custom 500 handler to provide additional context when rendering 500.html.
+    """
+    try:
+        template = loader.get_template(template_name)
+    except TemplateDoesNotExist:
+        return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
+    type_, error, traceback = sys.exc_info()
+
+    return HttpResponseServerError(template.render({
+        'error': error,
+        'exception': str(type_),
+        'netbox_version': settings.VERSION,
+        'python_version': platform.python_version(),
+    }))

+ 1223 - 0
netbox/netbox/views/generic.py

@@ -0,0 +1,1223 @@
+import logging
+import re
+from copy import deepcopy
+
+from django.contrib import messages
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
+from django.db import transaction, IntegrityError
+from django.db.models import ManyToManyField, ProtectedError
+from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.html import escape
+from django.utils.http import is_safe_url
+from django.utils.safestring import mark_safe
+from django.views.generic import View
+from django_tables2 import RequestConfig
+
+from extras.models import CustomField, ExportTemplate
+from utilities.error_handlers import handle_protectederror
+from utilities.exceptions import AbortTransaction
+from utilities.forms import (
+    BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields,
+)
+from utilities.paginator import EnhancedPaginator, get_paginate_count
+from utilities.permissions import get_permission_for_model
+from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields
+from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
+
+
+class ObjectView(ObjectPermissionRequiredMixin, View):
+    """
+    Retrieve a single object for display.
+
+    queryset: The base queryset for retrieving the object.
+    """
+    queryset = None
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'view')
+
+    def get_template_name(self):
+        """
+        Return self.template_name if set. Otherwise, resolve the template path by model app_label and name.
+        """
+        if hasattr(self, 'template_name'):
+            return self.template_name
+        model_opts = self.queryset.model._meta
+        return f'{model_opts.app_label}/{model_opts.model_name}.html'
+
+    def get(self, request, pk):
+        """
+        Generic GET handler for accessing an object by PK
+        """
+        instance = get_object_or_404(self.queryset, pk=pk)
+
+        return render(request, self.get_template_name(), {
+            'instance': instance,
+        })
+
+
+class ObjectListView(ObjectPermissionRequiredMixin, View):
+    """
+    List a series of objects.
+
+    queryset: The queryset of objects to display. Note: Prefetching related objects is not necessary, as the
+      table will prefetch objects as needed depending on the columns being displayed.
+    filter: A django-filter FilterSet that is applied to the queryset
+    filter_form: The form used to render filter options
+    table: The django-tables2 Table used to render the objects list
+    template_name: The name of the template
+    """
+    queryset = None
+    filterset = None
+    filterset_form = None
+    table = None
+    template_name = 'utilities/obj_list.html'
+    action_buttons = ('add', 'import', 'export')
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'view')
+
+    def queryset_to_yaml(self):
+        """
+        Export the queryset of objects as concatenated YAML documents.
+        """
+        yaml_data = [obj.to_yaml() for obj in self.queryset]
+
+        return '---\n'.join(yaml_data)
+
+    def queryset_to_csv(self):
+        """
+        Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
+        """
+        csv_data = []
+        custom_fields = []
+
+        # Start with the column headers
+        headers = self.queryset.model.csv_headers.copy()
+
+        # Add custom field headers, if any
+        if hasattr(self.queryset.model, 'custom_field_data'):
+            for custom_field in CustomField.objects.get_for_model(self.queryset.model):
+                headers.append(custom_field.name)
+                custom_fields.append(custom_field.name)
+
+        csv_data.append(','.join(headers))
+
+        # Iterate through the queryset appending each object
+        for obj in self.queryset:
+            data = obj.to_csv()
+
+            for custom_field in custom_fields:
+                data += (obj.cf.get(custom_field, ''),)
+
+            csv_data.append(csv_format(data))
+
+        return '\n'.join(csv_data)
+
+    def get(self, request):
+
+        model = self.queryset.model
+        content_type = ContentType.objects.get_for_model(model)
+
+        if self.filterset:
+            self.queryset = self.filterset(request.GET, self.queryset).qs
+
+        # Check for export template rendering
+        if request.GET.get('export'):
+            et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
+            try:
+                return et.render_to_response(self.queryset)
+            except Exception as e:
+                messages.error(
+                    request,
+                    "There was an error rendering the selected export template ({}): {}".format(
+                        et.name, e
+                    )
+                )
+
+        # Check for YAML export support
+        elif 'export' in request.GET and hasattr(model, 'to_yaml'):
+            response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
+            filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
+            response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
+            return response
+
+        # Fall back to built-in CSV formatting if export requested but no template specified
+        elif 'export' in request.GET and hasattr(model, 'to_csv'):
+            response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
+            filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
+            response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
+            return response
+
+        # Compile a dictionary indicating which permissions are available to the current user for this model
+        permissions = {}
+        for action in ('add', 'change', 'delete', 'view'):
+            perm_name = get_permission_for_model(model, action)
+            permissions[action] = request.user.has_perm(perm_name)
+
+        # Construct the objects table
+        table = self.table(self.queryset, user=request.user)
+        if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
+            table.columns.show('pk')
+
+        # Apply the request context
+        paginate = {
+            'paginator_class': EnhancedPaginator,
+            'per_page': get_paginate_count(request)
+        }
+        RequestConfig(request, paginate).configure(table)
+
+        context = {
+            'content_type': content_type,
+            'table': table,
+            'permissions': permissions,
+            'action_buttons': self.action_buttons,
+            'table_config_form': TableConfigForm(table=table),
+            'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
+        }
+        context.update(self.extra_context())
+
+        return render(request, self.template_name, context)
+
+    def extra_context(self):
+        return {}
+
+
+class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    Create or edit a single object.
+
+    queryset: The base queryset for the object being modified
+    model_form: The form used to create or edit the object
+    template_name: The name of the template
+    """
+    queryset = None
+    model_form = None
+    template_name = 'utilities/obj_edit.html'
+
+    def get_required_permission(self):
+        # self._permission_action is set by dispatch() to either "add" or "change" depending on whether
+        # we are modifying an existing object or creating a new one.
+        return get_permission_for_model(self.queryset.model, self._permission_action)
+
+    def get_object(self, kwargs):
+        # Look up an existing object by slug or PK, if provided.
+        if 'slug' in kwargs:
+            return get_object_or_404(self.queryset, slug=kwargs['slug'])
+        elif 'pk' in kwargs:
+            return get_object_or_404(self.queryset, pk=kwargs['pk'])
+        # Otherwise, return a new instance.
+        return self.queryset.model()
+
+    def alter_obj(self, obj, request, url_args, url_kwargs):
+        # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined
+        # given some parameter from the request URL.
+        return obj
+
+    def dispatch(self, request, *args, **kwargs):
+        # Determine required permission based on whether we are editing an existing object
+        self._permission_action = 'change' if kwargs else 'add'
+
+        return super().dispatch(request, *args, **kwargs)
+
+    def get(self, request, *args, **kwargs):
+        obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
+
+        initial_data = normalize_querydict(request.GET)
+        form = self.model_form(instance=obj, initial=initial_data)
+        restrict_form_fields(form, request.user)
+
+        return render(request, self.template_name, {
+            'obj': obj,
+            'obj_type': self.queryset.model._meta.verbose_name,
+            'form': form,
+            'return_url': self.get_return_url(request, obj),
+        })
+
+    def post(self, request, *args, **kwargs):
+        logger = logging.getLogger('netbox.views.ObjectEditView')
+        obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
+        form = self.model_form(
+            data=request.POST,
+            files=request.FILES,
+            instance=obj
+        )
+        restrict_form_fields(form, request.user)
+
+        if form.is_valid():
+            logger.debug("Form validation was successful")
+
+            try:
+                with transaction.atomic():
+                    object_created = form.instance.pk is None
+                    obj = form.save()
+
+                    # Check that the new object conforms with any assigned object-level permissions
+                    self.queryset.get(pk=obj.pk)
+
+                msg = '{} {}'.format(
+                    'Created' if object_created else 'Modified',
+                    self.queryset.model._meta.verbose_name
+                )
+                logger.info(f"{msg} {obj} (PK: {obj.pk})")
+                if hasattr(obj, 'get_absolute_url'):
+                    msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
+                else:
+                    msg = '{} {}'.format(msg, escape(obj))
+                messages.success(request, mark_safe(msg))
+
+                if '_addanother' in request.POST:
+
+                    # If the object has clone_fields, pre-populate a new instance of the form
+                    if hasattr(obj, 'clone_fields'):
+                        url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
+                        return redirect(url)
+
+                    return redirect(request.get_full_path())
+
+                return_url = form.cleaned_data.get('return_url')
+                if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
+                    return redirect(return_url)
+                else:
+                    return redirect(self.get_return_url(request, obj))
+
+            except ObjectDoesNotExist:
+                msg = "Object save failed due to object-level permissions violation"
+                logger.debug(msg)
+                form.add_error(None, msg)
+
+        else:
+            logger.debug("Form validation failed")
+
+        return render(request, self.template_name, {
+            'obj': obj,
+            'obj_type': self.queryset.model._meta.verbose_name,
+            'form': form,
+            'return_url': self.get_return_url(request, obj),
+        })
+
+
+class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    Delete a single object.
+
+    queryset: The base queryset for the object being deleted
+    template_name: The name of the template
+    """
+    queryset = None
+    template_name = 'utilities/obj_delete.html'
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'delete')
+
+    def get_object(self, kwargs):
+        # Look up object by slug if one has been provided. Otherwise, use PK.
+        if 'slug' in kwargs:
+            return get_object_or_404(self.queryset, slug=kwargs['slug'])
+        else:
+            return get_object_or_404(self.queryset, pk=kwargs['pk'])
+
+    def get(self, request, **kwargs):
+        obj = self.get_object(kwargs)
+        form = ConfirmationForm(initial=request.GET)
+
+        return render(request, self.template_name, {
+            'obj': obj,
+            'form': form,
+            'obj_type': self.queryset.model._meta.verbose_name,
+            'return_url': self.get_return_url(request, obj),
+        })
+
+    def post(self, request, **kwargs):
+        logger = logging.getLogger('netbox.views.ObjectDeleteView')
+        obj = self.get_object(kwargs)
+        form = ConfirmationForm(request.POST)
+
+        if form.is_valid():
+            logger.debug("Form validation was successful")
+
+            try:
+                obj.delete()
+            except ProtectedError as e:
+                logger.info("Caught ProtectedError while attempting to delete object")
+                handle_protectederror([obj], request, e)
+                return redirect(obj.get_absolute_url())
+
+            msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
+            logger.info(msg)
+            messages.success(request, msg)
+
+            return_url = form.cleaned_data.get('return_url')
+            if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
+                return redirect(return_url)
+            else:
+                return redirect(self.get_return_url(request, obj))
+
+        else:
+            logger.debug("Form validation failed")
+
+        return render(request, self.template_name, {
+            'obj': obj,
+            'form': form,
+            'obj_type': self.queryset.model._meta.verbose_name,
+            'return_url': self.get_return_url(request, obj),
+        })
+
+
+class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    Create new objects in bulk.
+
+    queryset: Base queryset for the objects being created
+    form: Form class which provides the `pattern` field
+    model_form: The ModelForm used to create individual objects
+    pattern_target: Name of the field to be evaluated as a pattern (if any)
+    template_name: The name of the template
+    """
+    queryset = None
+    form = None
+    model_form = None
+    pattern_target = ''
+    template_name = None
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'add')
+
+    def get(self, request):
+        # Set initial values for visible form fields from query args
+        initial = {}
+        for field in getattr(self.model_form._meta, 'fields', []):
+            if request.GET.get(field):
+                initial[field] = request.GET[field]
+
+        form = self.form()
+        model_form = self.model_form(initial=initial)
+
+        return render(request, self.template_name, {
+            'obj_type': self.model_form._meta.model._meta.verbose_name,
+            'form': form,
+            'model_form': model_form,
+            'return_url': self.get_return_url(request),
+        })
+
+    def post(self, request):
+        logger = logging.getLogger('netbox.views.BulkCreateView')
+        model = self.queryset.model
+        form = self.form(request.POST)
+        model_form = self.model_form(request.POST)
+
+        if form.is_valid():
+            logger.debug("Form validation was successful")
+            pattern = form.cleaned_data['pattern']
+            new_objs = []
+
+            try:
+                with transaction.atomic():
+
+                    # Create objects from the expanded. Abort the transaction on the first validation error.
+                    for value in pattern:
+
+                        # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
+                        # copy of the POST QueryDict so that we can update the target field value.
+                        model_form = self.model_form(request.POST.copy())
+                        model_form.data[self.pattern_target] = value
+
+                        # Validate each new object independently.
+                        if model_form.is_valid():
+                            obj = model_form.save()
+                            logger.debug(f"Created {obj} (PK: {obj.pk})")
+                            new_objs.append(obj)
+                        else:
+                            # Copy any errors on the pattern target field to the pattern form.
+                            errors = model_form.errors.as_data()
+                            if errors.get(self.pattern_target):
+                                form.add_error('pattern', errors[self.pattern_target])
+                            # Raise an IntegrityError to break the for loop and abort the transaction.
+                            raise IntegrityError()
+
+                    # Enforce object-level permissions
+                    if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
+                        raise ObjectDoesNotExist
+
+                    # If we make it to this point, validation has succeeded on all new objects.
+                    msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
+                    logger.info(msg)
+                    messages.success(request, msg)
+
+                    if '_addanother' in request.POST:
+                        return redirect(request.path)
+                    return redirect(self.get_return_url(request))
+
+            except IntegrityError:
+                pass
+
+            except ObjectDoesNotExist:
+                msg = "Object creation failed due to object-level permissions violation"
+                logger.debug(msg)
+                form.add_error(None, msg)
+
+        else:
+            logger.debug("Form validation failed")
+
+        return render(request, self.template_name, {
+            'form': form,
+            'model_form': model_form,
+            'obj_type': model._meta.verbose_name,
+            'return_url': self.get_return_url(request),
+        })
+
+
+class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    Import a single object (YAML or JSON format).
+
+    queryset: Base queryset for the objects being created
+    model_form: The ModelForm used to create individual objects
+    related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects
+    template_name: The name of the template
+    """
+    queryset = None
+    model_form = None
+    related_object_forms = dict()
+    template_name = 'utilities/obj_import.html'
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'add')
+
+    def get(self, request):
+        form = ImportForm()
+
+        return render(request, self.template_name, {
+            'form': form,
+            'obj_type': self.queryset.model._meta.verbose_name,
+            'return_url': self.get_return_url(request),
+        })
+
+    def post(self, request):
+        logger = logging.getLogger('netbox.views.ObjectImportView')
+        form = ImportForm(request.POST)
+
+        if form.is_valid():
+            logger.debug("Import form validation was successful")
+
+            # Initialize model form
+            data = form.cleaned_data['data']
+            model_form = self.model_form(data)
+            restrict_form_fields(model_form, request.user)
+
+            # Assign default values for any fields which were not specified. We have to do this manually because passing
+            # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not
+            # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the
+            # applicable field defaults as needed prior to form validation.
+            for field_name, field in model_form.fields.items():
+                if field_name not in data and hasattr(field, 'initial'):
+                    model_form.data[field_name] = field.initial
+
+            if model_form.is_valid():
+
+                try:
+                    with transaction.atomic():
+
+                        # Save the primary object
+                        obj = model_form.save()
+
+                        # Enforce object-level permissions
+                        self.queryset.get(pk=obj.pk)
+
+                        logger.debug(f"Created {obj} (PK: {obj.pk})")
+
+                        # Iterate through the related object forms (if any), validating and saving each instance.
+                        for field_name, related_object_form in self.related_object_forms.items():
+                            logger.debug("Processing form for related objects: {related_object_form}")
+
+                            related_obj_pks = []
+                            for i, rel_obj_data in enumerate(data.get(field_name, list())):
+
+                                f = related_object_form(obj, rel_obj_data)
+
+                                for subfield_name, field in f.fields.items():
+                                    if subfield_name not in rel_obj_data and hasattr(field, 'initial'):
+                                        f.data[subfield_name] = field.initial
+
+                                if f.is_valid():
+                                    related_obj = f.save()
+                                    related_obj_pks.append(related_obj.pk)
+                                else:
+                                    # Replicate errors on the related object form to the primary form for display
+                                    for subfield_name, errors in f.errors.items():
+                                        for err in errors:
+                                            err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
+                                            model_form.add_error(None, err_msg)
+                                    raise AbortTransaction()
+
+                            # Enforce object-level permissions on related objects
+                            model = related_object_form.Meta.model
+                            if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks):
+                                raise ObjectDoesNotExist
+
+                except AbortTransaction:
+                    pass
+
+                except ObjectDoesNotExist:
+                    msg = "Object creation failed due to object-level permissions violation"
+                    logger.debug(msg)
+                    form.add_error(None, msg)
+
+            if not model_form.errors:
+                logger.info(f"Import object {obj} (PK: {obj.pk})")
+                messages.success(request, mark_safe('Imported object: <a href="{}">{}</a>'.format(
+                    obj.get_absolute_url(), obj
+                )))
+
+                if '_addanother' in request.POST:
+                    return redirect(request.get_full_path())
+
+                return_url = form.cleaned_data.get('return_url')
+                if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
+                    return redirect(return_url)
+                else:
+                    return redirect(self.get_return_url(request, obj))
+
+            else:
+                logger.debug("Model form validation failed")
+
+                # Replicate model form errors for display
+                for field, errors in model_form.errors.items():
+                    for err in errors:
+                        if field == '__all__':
+                            form.add_error(None, err)
+                        else:
+                            form.add_error(None, "{}: {}".format(field, err))
+
+        else:
+            logger.debug("Import form validation failed")
+
+        return render(request, self.template_name, {
+            'form': form,
+            'obj_type': self.queryset.model._meta.verbose_name,
+            'return_url': self.get_return_url(request),
+        })
+
+
+class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    Import objects in bulk (CSV format).
+
+    queryset: Base queryset for the model
+    model_form: The form used to create each imported object
+    table: The django-tables2 Table used to render the list of imported objects
+    template_name: The name of the template
+    widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
+    """
+    queryset = None
+    model_form = None
+    table = None
+    template_name = 'utilities/obj_bulk_import.html'
+    widget_attrs = {}
+
+    def _import_form(self, *args, **kwargs):
+
+        class ImportForm(BootstrapMixin, Form):
+            csv = CSVDataField(
+                from_form=self.model_form,
+                widget=Textarea(attrs=self.widget_attrs)
+            )
+
+        return ImportForm(*args, **kwargs)
+
+    def _save_obj(self, obj_form, request):
+        """
+        Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data).
+        """
+        return obj_form.save()
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'add')
+
+    def get(self, request):
+
+        return render(request, self.template_name, {
+            'form': self._import_form(),
+            'fields': self.model_form().fields,
+            'obj_type': self.model_form._meta.model._meta.verbose_name,
+            'return_url': self.get_return_url(request),
+        })
+
+    def post(self, request):
+        logger = logging.getLogger('netbox.views.BulkImportView')
+        new_objs = []
+        form = self._import_form(request.POST)
+
+        if form.is_valid():
+            logger.debug("Form validation was successful")
+
+            try:
+                # Iterate through CSV data and bind each row to a new model form instance.
+                with transaction.atomic():
+                    headers, records = form.cleaned_data['csv']
+                    for row, data in enumerate(records, start=1):
+                        obj_form = self.model_form(data, headers=headers)
+                        restrict_form_fields(obj_form, request.user)
+
+                        if obj_form.is_valid():
+                            obj = self._save_obj(obj_form, request)
+                            new_objs.append(obj)
+                        else:
+                            for field, err in obj_form.errors.items():
+                                form.add_error('csv', "Row {} {}: {}".format(row, field, err[0]))
+                            raise ValidationError("")
+
+                    # Enforce object-level permissions
+                    if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
+                        raise ObjectDoesNotExist
+
+                # Compile a table containing the imported objects
+                obj_table = self.table(new_objs)
+
+                if new_objs:
+                    msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
+                    logger.info(msg)
+                    messages.success(request, msg)
+
+                    return render(request, "import_success.html", {
+                        'table': obj_table,
+                        'return_url': self.get_return_url(request),
+                    })
+
+            except ValidationError:
+                pass
+
+            except ObjectDoesNotExist:
+                msg = "Object import failed due to object-level permissions violation"
+                logger.debug(msg)
+                form.add_error(None, msg)
+
+        else:
+            logger.debug("Form validation failed")
+
+        return render(request, self.template_name, {
+            'form': form,
+            'fields': self.model_form().fields,
+            'obj_type': self.model_form._meta.model._meta.verbose_name,
+            'return_url': self.get_return_url(request),
+        })
+
+
+class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    Edit objects in bulk.
+
+    queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
+    filter: FilterSet to apply when deleting by QuerySet
+    table: The table used to display devices being edited
+    form: The form class used to edit objects in bulk
+    template_name: The name of the template
+    """
+    queryset = None
+    filterset = None
+    table = None
+    form = None
+    template_name = 'utilities/obj_bulk_edit.html'
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'change')
+
+    def get(self, request):
+        return redirect(self.get_return_url(request))
+
+    def post(self, request, **kwargs):
+        logger = logging.getLogger('netbox.views.BulkEditView')
+        model = self.queryset.model
+
+        # If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
+        if request.POST.get('_all') and self.filterset is not None:
+            pk_list = [
+                obj.pk for obj in self.filterset(request.GET, self.queryset.only('pk')).qs
+            ]
+        else:
+            pk_list = request.POST.getlist('pk')
+
+        if '_apply' in request.POST:
+            form = self.form(model, request.POST)
+            restrict_form_fields(form, request.user)
+
+            if form.is_valid():
+                logger.debug("Form validation was successful")
+                custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
+                standard_fields = [
+                    field for field in form.fields if field not in custom_fields + ['pk']
+                ]
+                nullified_fields = request.POST.getlist('_nullify')
+
+                try:
+
+                    with transaction.atomic():
+
+                        updated_objects = []
+                        for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
+
+                            # Update standard fields. If a field is listed in _nullify, delete its value.
+                            for name in standard_fields:
+
+                                try:
+                                    model_field = model._meta.get_field(name)
+                                except FieldDoesNotExist:
+                                    # This form field is used to modify a field rather than set its value directly
+                                    model_field = None
+
+                                # Handle nullification
+                                if name in form.nullable_fields and name in nullified_fields:
+                                    if isinstance(model_field, ManyToManyField):
+                                        getattr(obj, name).set([])
+                                    else:
+                                        setattr(obj, name, None if model_field.null else '')
+
+                                # ManyToManyFields
+                                elif isinstance(model_field, ManyToManyField):
+                                    if form.cleaned_data[name]:
+                                        getattr(obj, name).set(form.cleaned_data[name])
+                                # Normal fields
+                                elif form.cleaned_data[name] not in (None, ''):
+                                    setattr(obj, name, form.cleaned_data[name])
+
+                            # Update custom fields
+                            for name in custom_fields:
+                                if name in form.nullable_fields and name in nullified_fields:
+                                    obj.custom_field_data.pop(name, None)
+                                else:
+                                    obj.custom_field_data[name] = form.cleaned_data[name]
+
+                            obj.full_clean()
+                            obj.save()
+                            updated_objects.append(obj)
+                            logger.debug(f"Saved {obj} (PK: {obj.pk})")
+
+                            # Add/remove tags
+                            if form.cleaned_data.get('add_tags', None):
+                                obj.tags.add(*form.cleaned_data['add_tags'])
+                            if form.cleaned_data.get('remove_tags', None):
+                                obj.tags.remove(*form.cleaned_data['remove_tags'])
+
+                        # Enforce object-level permissions
+                        if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
+                            raise ObjectDoesNotExist
+
+                    if updated_objects:
+                        msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
+                        logger.info(msg)
+                        messages.success(self.request, msg)
+
+                    return redirect(self.get_return_url(request))
+
+                except ValidationError as e:
+                    messages.error(self.request, "{} failed validation: {}".format(obj, e))
+
+                except ObjectDoesNotExist:
+                    msg = "Object update failed due to object-level permissions violation"
+                    logger.debug(msg)
+                    form.add_error(None, msg)
+
+            else:
+                logger.debug("Form validation failed")
+
+        else:
+            # Include the PK list as initial data for the form
+            initial_data = {'pk': pk_list}
+
+            # Check for other contextual data needed for the form. We avoid passing all of request.GET because the
+            # filter values will conflict with the bulk edit form fields.
+            # TODO: Find a better way to accomplish this
+            if 'device' in request.GET:
+                initial_data['device'] = request.GET.get('device')
+            elif 'device_type' in request.GET:
+                initial_data['device_type'] = request.GET.get('device_type')
+
+            form = self.form(model, initial=initial_data)
+            restrict_form_fields(form, request.user)
+
+        # Retrieve objects being edited
+        table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
+        if not table.rows:
+            messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
+            return redirect(self.get_return_url(request))
+
+        return render(request, self.template_name, {
+            'form': form,
+            'table': table,
+            'obj_type_plural': model._meta.verbose_name_plural,
+            'return_url': self.get_return_url(request),
+        })
+
+
+class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    An extendable view for renaming objects in bulk.
+    """
+    queryset = None
+    template_name = 'utilities/obj_bulk_rename.html'
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Create a new Form class from BulkRenameForm
+        class _Form(BulkRenameForm):
+            pk = ModelMultipleChoiceField(
+                queryset=self.queryset,
+                widget=MultipleHiddenInput()
+            )
+
+        self.form = _Form
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'change')
+
+    def post(self, request):
+        logger = logging.getLogger('netbox.views.BulkRenameView')
+
+        if '_preview' in request.POST or '_apply' in request.POST:
+            form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
+            selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
+
+            if form.is_valid():
+                try:
+                    with transaction.atomic():
+                        renamed_pks = []
+                        for obj in selected_objects:
+                            find = form.cleaned_data['find']
+                            replace = form.cleaned_data['replace']
+                            if form.cleaned_data['use_regex']:
+                                try:
+                                    obj.new_name = re.sub(find, replace, obj.name)
+                                # Catch regex group reference errors
+                                except re.error:
+                                    obj.new_name = obj.name
+                            else:
+                                obj.new_name = obj.name.replace(find, replace)
+                            renamed_pks.append(obj.pk)
+
+                        if '_apply' in request.POST:
+                            for obj in selected_objects:
+                                obj.name = obj.new_name
+                                obj.save()
+
+                            # Enforce constrained permissions
+                            if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
+                                raise ObjectDoesNotExist
+
+                            messages.success(request, "Renamed {} {}".format(
+                                len(selected_objects),
+                                self.queryset.model._meta.verbose_name_plural
+                            ))
+                            return redirect(self.get_return_url(request))
+
+                except ObjectDoesNotExist:
+                    msg = "Object update failed due to object-level permissions violation"
+                    logger.debug(msg)
+                    form.add_error(None, msg)
+
+        else:
+            form = self.form(initial={'pk': request.POST.getlist('pk')})
+            selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
+
+        return render(request, self.template_name, {
+            'form': form,
+            'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
+            'selected_objects': selected_objects,
+            'return_url': self.get_return_url(request),
+        })
+
+
+class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    Delete objects in bulk.
+
+    queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
+    filter: FilterSet to apply when deleting by QuerySet
+    table: The table used to display devices being deleted
+    form: The form class used to delete objects in bulk
+    template_name: The name of the template
+    """
+    queryset = None
+    filterset = None
+    table = None
+    form = None
+    template_name = 'utilities/obj_bulk_delete.html'
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'delete')
+
+    def get(self, request):
+        return redirect(self.get_return_url(request))
+
+    def post(self, request, **kwargs):
+        logger = logging.getLogger('netbox.views.BulkDeleteView')
+        model = self.queryset.model
+
+        # Are we deleting *all* objects in the queryset or just a selected subset?
+        if request.POST.get('_all'):
+            if self.filterset is not None:
+                pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
+            else:
+                pk_list = model.objects.values_list('pk', flat=True)
+        else:
+            pk_list = [int(pk) for pk in request.POST.getlist('pk')]
+
+        form_cls = self.get_form()
+
+        if '_confirm' in request.POST:
+            form = form_cls(request.POST)
+            if form.is_valid():
+                logger.debug("Form validation was successful")
+
+                # Delete objects
+                queryset = self.queryset.filter(pk__in=pk_list)
+                try:
+                    deleted_count = queryset.delete()[1][model._meta.label]
+                except ProtectedError as e:
+                    logger.info("Caught ProtectedError while attempting to delete objects")
+                    handle_protectederror(queryset, request, e)
+                    return redirect(self.get_return_url(request))
+
+                msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural)
+                logger.info(msg)
+                messages.success(request, msg)
+                return redirect(self.get_return_url(request))
+
+            else:
+                logger.debug("Form validation failed")
+
+        else:
+            form = form_cls(initial={
+                'pk': pk_list,
+                'return_url': self.get_return_url(request),
+            })
+
+        # Retrieve objects being deleted
+        table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
+        if not table.rows:
+            messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
+            return redirect(self.get_return_url(request))
+
+        return render(request, self.template_name, {
+            'form': form,
+            'obj_type_plural': model._meta.verbose_name_plural,
+            'table': table,
+            'return_url': self.get_return_url(request),
+        })
+
+    def get_form(self):
+        """
+        Provide a standard bulk delete form if none has been specified for the view
+        """
+        class BulkDeleteForm(ConfirmationForm):
+            pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
+
+        if self.form:
+            return self.form
+
+        return BulkDeleteForm
+
+
+#
+# Device/VirtualMachine components
+#
+
+# TODO: Replace with BulkCreateView
+class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
+    """
+    queryset = None
+    form = None
+    model_form = None
+    template_name = None
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'add')
+
+    def get(self, request):
+
+        form = self.form(initial=request.GET)
+
+        return render(request, self.template_name, {
+            'component_type': self.queryset.model._meta.verbose_name,
+            'form': form,
+            'return_url': self.get_return_url(request),
+        })
+
+    def post(self, request):
+        logger = logging.getLogger('netbox.views.ComponentCreateView')
+        form = self.form(request.POST, initial=request.GET)
+
+        if form.is_valid():
+
+            new_components = []
+            data = deepcopy(request.POST)
+
+            names = form.cleaned_data['name_pattern']
+            labels = form.cleaned_data.get('label_pattern')
+            for i, name in enumerate(names):
+                label = labels[i] if labels else None
+                # Initialize the individual component form
+                data['name'] = name
+                data['label'] = label
+                if hasattr(form, 'get_iterative_data'):
+                    data.update(form.get_iterative_data(i))
+                component_form = self.model_form(data)
+
+                if component_form.is_valid():
+                    new_components.append(component_form)
+                else:
+                    for field, errors in component_form.errors.as_data().items():
+                        # Assign errors on the child form's name/label field to name_pattern/label_pattern on the parent form
+                        if field == 'name':
+                            field = 'name_pattern'
+                        elif field == 'label':
+                            field = 'label_pattern'
+                        for e in errors:
+                            form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
+
+            if not form.errors:
+
+                try:
+
+                    with transaction.atomic():
+
+                        # Create the new components
+                        new_objs = []
+                        for component_form in new_components:
+                            obj = component_form.save()
+                            new_objs.append(obj)
+
+                        # Enforce object-level permissions
+                        if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
+                            raise ObjectDoesNotExist
+
+                    messages.success(request, "Added {} {}".format(
+                        len(new_components), self.queryset.model._meta.verbose_name_plural
+                    ))
+                    if '_addanother' in request.POST:
+                        return redirect(request.get_full_path())
+                    else:
+                        return redirect(self.get_return_url(request))
+
+                except ObjectDoesNotExist:
+                    msg = "Component creation failed due to object-level permissions violation"
+                    logger.debug(msg)
+                    form.add_error(None, msg)
+
+        return render(request, self.template_name, {
+            'component_type': self.queryset.model._meta.verbose_name,
+            'form': form,
+            'return_url': self.get_return_url(request),
+        })
+
+
+class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
+    """
+    parent_model = None
+    parent_field = None
+    form = None
+    queryset = None
+    model_form = None
+    filterset = None
+    table = None
+    template_name = 'utilities/obj_bulk_add_component.html'
+
+    def get_required_permission(self):
+        return f'dcim.add_{self.queryset.model._meta.model_name}'
+
+    def post(self, request):
+        logger = logging.getLogger('netbox.views.BulkComponentCreateView')
+        parent_model_name = self.parent_model._meta.verbose_name_plural
+        model_name = self.queryset.model._meta.verbose_name_plural
+
+        # Are we editing *all* objects in the queryset or just a selected subset?
+        if request.POST.get('_all') and self.filterset is not None:
+            pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs]
+        else:
+            pk_list = [int(pk) for pk in request.POST.getlist('pk')]
+
+        selected_objects = self.parent_model.objects.filter(pk__in=pk_list)
+        if not selected_objects:
+            messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
+            return redirect(self.get_return_url(request))
+        table = self.table(selected_objects)
+
+        if '_create' in request.POST:
+            form = self.form(request.POST)
+
+            if form.is_valid():
+                logger.debug("Form validation was successful")
+
+                new_components = []
+                data = deepcopy(form.cleaned_data)
+
+                try:
+                    with transaction.atomic():
+
+                        for obj in data['pk']:
+
+                            names = data['name_pattern']
+                            labels = data['label_pattern'] if 'label_pattern' in data else None
+                            for i, name in enumerate(names):
+                                label = labels[i] if labels else None
+
+                                component_data = {
+                                    self.parent_field: obj.pk,
+                                    'name': name,
+                                    'label': label
+                                }
+                                component_data.update(data)
+                                component_form = self.model_form(component_data)
+                                if component_form.is_valid():
+                                    instance = component_form.save()
+                                    logger.debug(f"Created {instance} on {instance.parent}")
+                                    new_components.append(instance)
+                                else:
+                                    for field, errors in component_form.errors.as_data().items():
+                                        for e in errors:
+                                            form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
+
+                        # Enforce object-level permissions
+                        if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
+                            raise ObjectDoesNotExist
+
+                except IntegrityError:
+                    pass
+
+                except ObjectDoesNotExist:
+                    msg = "Component creation failed due to object-level permissions violation"
+                    logger.debug(msg)
+                    form.add_error(None, msg)
+
+                if not form.errors:
+                    msg = "Added {} {} to {} {}.".format(
+                        len(new_components),
+                        model_name,
+                        len(form.cleaned_data['pk']),
+                        parent_model_name
+                    )
+                    logger.info(msg)
+                    messages.success(request, msg)
+
+                    return redirect(self.get_return_url(request))
+
+            else:
+                logger.debug("Form validation failed")
+
+        else:
+            form = self.form(initial={'pk': pk_list})
+
+        return render(request, self.template_name, {
+            'form': form,
+            'parent_model_name': parent_model_name,
+            'model_name': model_name,
+            'table': table,
+            'return_url': self.get_return_url(request),
+        })

+ 13 - 15
netbox/secrets/views.py

@@ -7,9 +7,7 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 
-from utilities.views import (
-    BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
-)
+from netbox.views import generic
 from . import filters, forms, tables
 from .models import SecretRole, Secret, SessionKey, UserKey
 
@@ -28,27 +26,27 @@ def get_session_key(request):
 # Secret roles
 #
 
-class SecretRoleListView(ObjectListView):
+class SecretRoleListView(generic.ObjectListView):
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets')).order_by(*SecretRole._meta.ordering)
     table = tables.SecretRoleTable
 
 
-class SecretRoleEditView(ObjectEditView):
+class SecretRoleEditView(generic.ObjectEditView):
     queryset = SecretRole.objects.all()
     model_form = forms.SecretRoleForm
 
 
-class SecretRoleDeleteView(ObjectDeleteView):
+class SecretRoleDeleteView(generic.ObjectDeleteView):
     queryset = SecretRole.objects.all()
 
 
-class SecretRoleBulkImportView(BulkImportView):
+class SecretRoleBulkImportView(generic.BulkImportView):
     queryset = SecretRole.objects.all()
     model_form = forms.SecretRoleCSVForm
     table = tables.SecretRoleTable
 
 
-class SecretRoleBulkDeleteView(BulkDeleteView):
+class SecretRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets')).order_by(*SecretRole._meta.ordering)
     table = tables.SecretRoleTable
 
@@ -57,7 +55,7 @@ class SecretRoleBulkDeleteView(BulkDeleteView):
 # Secrets
 #
 
-class SecretListView(ObjectListView):
+class SecretListView(generic.ObjectListView):
     queryset = Secret.objects.all()
     filterset = filters.SecretFilterSet
     filterset_form = forms.SecretFilterForm
@@ -65,7 +63,7 @@ class SecretListView(ObjectListView):
     action_buttons = ('import', 'export')
 
 
-class SecretView(ObjectView):
+class SecretView(generic.ObjectView):
     queryset = Secret.objects.all()
 
     def get(self, request, pk):
@@ -77,7 +75,7 @@ class SecretView(ObjectView):
         })
 
 
-class SecretEditView(ObjectEditView):
+class SecretEditView(generic.ObjectEditView):
     queryset = Secret.objects.all()
     model_form = forms.SecretForm
     template_name = 'secrets/secret_edit.html'
@@ -146,11 +144,11 @@ class SecretEditView(ObjectEditView):
         })
 
 
-class SecretDeleteView(ObjectDeleteView):
+class SecretDeleteView(generic.ObjectDeleteView):
     queryset = Secret.objects.all()
 
 
-class SecretBulkImportView(BulkImportView):
+class SecretBulkImportView(generic.BulkImportView):
     queryset = Secret.objects.all()
     model_form = forms.SecretCSVForm
     table = tables.SecretTable
@@ -197,14 +195,14 @@ class SecretBulkImportView(BulkImportView):
         })
 
 
-class SecretBulkEditView(BulkEditView):
+class SecretBulkEditView(generic.BulkEditView):
     queryset = Secret.objects.prefetch_related('role')
     filterset = filters.SecretFilterSet
     table = tables.SecretTable
     form = forms.SecretBulkEditForm
 
 
-class SecretBulkDeleteView(BulkDeleteView):
+class SecretBulkDeleteView(generic.BulkDeleteView):
     queryset = Secret.objects.prefetch_related('role')
     filterset = filters.SecretFilterSet
     table = tables.SecretTable

+ 13 - 16
netbox/tenancy/views.py

@@ -1,12 +1,9 @@
-from django.db.models import Count
 from django.shortcuts import get_object_or_404, render
 
 from circuits.models import Circuit
 from dcim.models import Site, Rack, Device, RackReservation
 from ipam.models import IPAddress, Prefix, VLAN, VRF
-from utilities.views import (
-    BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
-)
+from netbox.views import generic
 from virtualization.models import VirtualMachine, Cluster
 from . import filters, forms, tables
 from .models import Tenant, TenantGroup
@@ -16,7 +13,7 @@ from .models import Tenant, TenantGroup
 # Tenant groups
 #
 
-class TenantGroupListView(ObjectListView):
+class TenantGroupListView(generic.ObjectListView):
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),
         Tenant,
@@ -27,22 +24,22 @@ class TenantGroupListView(ObjectListView):
     table = tables.TenantGroupTable
 
 
-class TenantGroupEditView(ObjectEditView):
+class TenantGroupEditView(generic.ObjectEditView):
     queryset = TenantGroup.objects.all()
     model_form = forms.TenantGroupForm
 
 
-class TenantGroupDeleteView(ObjectDeleteView):
+class TenantGroupDeleteView(generic.ObjectDeleteView):
     queryset = TenantGroup.objects.all()
 
 
-class TenantGroupBulkImportView(BulkImportView):
+class TenantGroupBulkImportView(generic.BulkImportView):
     queryset = TenantGroup.objects.all()
     model_form = forms.TenantGroupCSVForm
     table = tables.TenantGroupTable
 
 
-class TenantGroupBulkDeleteView(BulkDeleteView):
+class TenantGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = TenantGroup.objects.add_related_count(
         TenantGroup.objects.all(),
         Tenant,
@@ -57,14 +54,14 @@ class TenantGroupBulkDeleteView(BulkDeleteView):
 #  Tenants
 #
 
-class TenantListView(ObjectListView):
+class TenantListView(generic.ObjectListView):
     queryset = Tenant.objects.all()
     filterset = filters.TenantFilterSet
     filterset_form = forms.TenantFilterForm
     table = tables.TenantTable
 
 
-class TenantView(ObjectView):
+class TenantView(generic.ObjectView):
     queryset = Tenant.objects.prefetch_related('group')
 
     def get(self, request, slug):
@@ -90,30 +87,30 @@ class TenantView(ObjectView):
         })
 
 
-class TenantEditView(ObjectEditView):
+class TenantEditView(generic.ObjectEditView):
     queryset = Tenant.objects.all()
     model_form = forms.TenantForm
     template_name = 'tenancy/tenant_edit.html'
 
 
-class TenantDeleteView(ObjectDeleteView):
+class TenantDeleteView(generic.ObjectDeleteView):
     queryset = Tenant.objects.all()
 
 
-class TenantBulkImportView(BulkImportView):
+class TenantBulkImportView(generic.BulkImportView):
     queryset = Tenant.objects.all()
     model_form = forms.TenantCSVForm
     table = tables.TenantTable
 
 
-class TenantBulkEditView(BulkEditView):
+class TenantBulkEditView(generic.BulkEditView):
     queryset = Tenant.objects.prefetch_related('group')
     filterset = filters.TenantFilterSet
     table = tables.TenantTable
     form = forms.TenantBulkEditForm
 
 
-class TenantBulkDeleteView(BulkDeleteView):
+class TenantBulkDeleteView(generic.BulkDeleteView):
     queryset = Tenant.objects.prefetch_related('group')
     filterset = filters.TenantFilterSet
     table = tables.TenantTable

+ 3 - 1250
netbox/utilities/views.py

@@ -1,43 +1,14 @@
-import logging
-import platform
-import re
-import sys
-from copy import deepcopy
-
-from django.conf import settings
-from django.contrib import messages
 from django.contrib.auth.mixins import AccessMixin
-from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
-from django.db import transaction, IntegrityError
-from django.db.models import ManyToManyField, ProtectedError
-from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
-from django.http import HttpResponse, HttpResponseServerError
-from django.shortcuts import get_object_or_404, redirect, render
-from django.template import loader
-from django.template.exceptions import TemplateDoesNotExist
+from django.core.exceptions import ImproperlyConfigured
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
-from django.utils.html import escape
 from django.utils.http import is_safe_url
-from django.utils.safestring import mark_safe
-from django.views.decorators.csrf import requires_csrf_token
-from django.views.defaults import ERROR_500_TEMPLATE_NAME
-from django.views.generic import View
-from django_tables2 import RequestConfig
 
-from extras.models import CustomField, ExportTemplate
-from utilities.exceptions import AbortTransaction
-from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields
-from utilities.permissions import get_permission_for_model, resolve_permission
-from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields
-from .error_handlers import handle_protectederror
-from .forms import ConfirmationForm, ImportForm
-from .paginator import EnhancedPaginator, get_paginate_count
+from .permissions import resolve_permission
 
 
 #
-# Mixins
+# View Mixins
 #
 
 class ContentTypePermissionRequiredMixin(AccessMixin):
@@ -152,1221 +123,3 @@ class GetReturnURLMixin:
 
         # If all else fails, return home. Ideally this should never happen.
         return reverse('home')
-
-
-#
-# Generic views
-#
-
-class ObjectView(ObjectPermissionRequiredMixin, View):
-    """
-    Retrieve a single object for display.
-
-    queryset: The base queryset for retrieving the object.
-    """
-    queryset = None
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'view')
-
-    def get_template_name(self):
-        """
-        Return self.template_name if set. Otherwise, resolve the template path by model app_label and name.
-        """
-        if hasattr(self, 'template_name'):
-            return self.template_name
-        model_opts = self.queryset.model._meta
-        return f'{model_opts.app_label}/{model_opts.model_name}.html'
-
-    def get(self, request, pk):
-        """
-        Generic GET handler for accessing an object by PK
-        """
-        instance = get_object_or_404(self.queryset, pk=pk)
-
-        return render(request, self.get_template_name(), {
-            'instance': instance,
-        })
-
-
-class ObjectListView(ObjectPermissionRequiredMixin, View):
-    """
-    List a series of objects.
-
-    queryset: The queryset of objects to display. Note: Prefetching related objects is not necessary, as the
-      table will prefetch objects as needed depending on the columns being displayed.
-    filter: A django-filter FilterSet that is applied to the queryset
-    filter_form: The form used to render filter options
-    table: The django-tables2 Table used to render the objects list
-    template_name: The name of the template
-    """
-    queryset = None
-    filterset = None
-    filterset_form = None
-    table = None
-    template_name = 'utilities/obj_list.html'
-    action_buttons = ('add', 'import', 'export')
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'view')
-
-    def queryset_to_yaml(self):
-        """
-        Export the queryset of objects as concatenated YAML documents.
-        """
-        yaml_data = [obj.to_yaml() for obj in self.queryset]
-
-        return '---\n'.join(yaml_data)
-
-    def queryset_to_csv(self):
-        """
-        Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
-        """
-        csv_data = []
-        custom_fields = []
-
-        # Start with the column headers
-        headers = self.queryset.model.csv_headers.copy()
-
-        # Add custom field headers, if any
-        if hasattr(self.queryset.model, 'custom_field_data'):
-            for custom_field in CustomField.objects.get_for_model(self.queryset.model):
-                headers.append(custom_field.name)
-                custom_fields.append(custom_field.name)
-
-        csv_data.append(','.join(headers))
-
-        # Iterate through the queryset appending each object
-        for obj in self.queryset:
-            data = obj.to_csv()
-
-            for custom_field in custom_fields:
-                data += (obj.cf.get(custom_field, ''),)
-
-            csv_data.append(csv_format(data))
-
-        return '\n'.join(csv_data)
-
-    def get(self, request):
-
-        model = self.queryset.model
-        content_type = ContentType.objects.get_for_model(model)
-
-        if self.filterset:
-            self.queryset = self.filterset(request.GET, self.queryset).qs
-
-        # Check for export template rendering
-        if request.GET.get('export'):
-            et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
-            try:
-                return et.render_to_response(self.queryset)
-            except Exception as e:
-                messages.error(
-                    request,
-                    "There was an error rendering the selected export template ({}): {}".format(
-                        et.name, e
-                    )
-                )
-
-        # Check for YAML export support
-        elif 'export' in request.GET and hasattr(model, 'to_yaml'):
-            response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml')
-            filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
-            response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
-            return response
-
-        # Fall back to built-in CSV formatting if export requested but no template specified
-        elif 'export' in request.GET and hasattr(model, 'to_csv'):
-            response = HttpResponse(self.queryset_to_csv(), content_type='text/csv')
-            filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural)
-            response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
-            return response
-
-        # Compile a dictionary indicating which permissions are available to the current user for this model
-        permissions = {}
-        for action in ('add', 'change', 'delete', 'view'):
-            perm_name = get_permission_for_model(model, action)
-            permissions[action] = request.user.has_perm(perm_name)
-
-        # Construct the objects table
-        table = self.table(self.queryset, user=request.user)
-        if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
-            table.columns.show('pk')
-
-        # Apply the request context
-        paginate = {
-            'paginator_class': EnhancedPaginator,
-            'per_page': get_paginate_count(request)
-        }
-        RequestConfig(request, paginate).configure(table)
-
-        context = {
-            'content_type': content_type,
-            'table': table,
-            'permissions': permissions,
-            'action_buttons': self.action_buttons,
-            'table_config_form': TableConfigForm(table=table),
-            'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
-        }
-        context.update(self.extra_context())
-
-        return render(request, self.template_name, context)
-
-    def extra_context(self):
-        return {}
-
-
-class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    Create or edit a single object.
-
-    queryset: The base queryset for the object being modified
-    model_form: The form used to create or edit the object
-    template_name: The name of the template
-    """
-    queryset = None
-    model_form = None
-    template_name = 'utilities/obj_edit.html'
-
-    def get_required_permission(self):
-        # self._permission_action is set by dispatch() to either "add" or "change" depending on whether
-        # we are modifying an existing object or creating a new one.
-        return get_permission_for_model(self.queryset.model, self._permission_action)
-
-    def get_object(self, kwargs):
-        # Look up an existing object by slug or PK, if provided.
-        if 'slug' in kwargs:
-            return get_object_or_404(self.queryset, slug=kwargs['slug'])
-        elif 'pk' in kwargs:
-            return get_object_or_404(self.queryset, pk=kwargs['pk'])
-        # Otherwise, return a new instance.
-        return self.queryset.model()
-
-    def alter_obj(self, obj, request, url_args, url_kwargs):
-        # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined
-        # given some parameter from the request URL.
-        return obj
-
-    def dispatch(self, request, *args, **kwargs):
-        # Determine required permission based on whether we are editing an existing object
-        self._permission_action = 'change' if kwargs else 'add'
-
-        return super().dispatch(request, *args, **kwargs)
-
-    def get(self, request, *args, **kwargs):
-        obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
-
-        initial_data = normalize_querydict(request.GET)
-        form = self.model_form(instance=obj, initial=initial_data)
-        restrict_form_fields(form, request.user)
-
-        return render(request, self.template_name, {
-            'obj': obj,
-            'obj_type': self.queryset.model._meta.verbose_name,
-            'form': form,
-            'return_url': self.get_return_url(request, obj),
-        })
-
-    def post(self, request, *args, **kwargs):
-        logger = logging.getLogger('netbox.views.ObjectEditView')
-        obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
-        form = self.model_form(
-            data=request.POST,
-            files=request.FILES,
-            instance=obj
-        )
-        restrict_form_fields(form, request.user)
-
-        if form.is_valid():
-            logger.debug("Form validation was successful")
-
-            try:
-                with transaction.atomic():
-                    object_created = form.instance.pk is None
-                    obj = form.save()
-
-                    # Check that the new object conforms with any assigned object-level permissions
-                    self.queryset.get(pk=obj.pk)
-
-                msg = '{} {}'.format(
-                    'Created' if object_created else 'Modified',
-                    self.queryset.model._meta.verbose_name
-                )
-                logger.info(f"{msg} {obj} (PK: {obj.pk})")
-                if hasattr(obj, 'get_absolute_url'):
-                    msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
-                else:
-                    msg = '{} {}'.format(msg, escape(obj))
-                messages.success(request, mark_safe(msg))
-
-                if '_addanother' in request.POST:
-
-                    # If the object has clone_fields, pre-populate a new instance of the form
-                    if hasattr(obj, 'clone_fields'):
-                        url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
-                        return redirect(url)
-
-                    return redirect(request.get_full_path())
-
-                return_url = form.cleaned_data.get('return_url')
-                if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
-                    return redirect(return_url)
-                else:
-                    return redirect(self.get_return_url(request, obj))
-
-            except ObjectDoesNotExist:
-                msg = "Object save failed due to object-level permissions violation"
-                logger.debug(msg)
-                form.add_error(None, msg)
-
-        else:
-            logger.debug("Form validation failed")
-
-        return render(request, self.template_name, {
-            'obj': obj,
-            'obj_type': self.queryset.model._meta.verbose_name,
-            'form': form,
-            'return_url': self.get_return_url(request, obj),
-        })
-
-
-class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    Delete a single object.
-
-    queryset: The base queryset for the object being deleted
-    template_name: The name of the template
-    """
-    queryset = None
-    template_name = 'utilities/obj_delete.html'
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'delete')
-
-    def get_object(self, kwargs):
-        # Look up object by slug if one has been provided. Otherwise, use PK.
-        if 'slug' in kwargs:
-            return get_object_or_404(self.queryset, slug=kwargs['slug'])
-        else:
-            return get_object_or_404(self.queryset, pk=kwargs['pk'])
-
-    def get(self, request, **kwargs):
-        obj = self.get_object(kwargs)
-        form = ConfirmationForm(initial=request.GET)
-
-        return render(request, self.template_name, {
-            'obj': obj,
-            'form': form,
-            'obj_type': self.queryset.model._meta.verbose_name,
-            'return_url': self.get_return_url(request, obj),
-        })
-
-    def post(self, request, **kwargs):
-        logger = logging.getLogger('netbox.views.ObjectDeleteView')
-        obj = self.get_object(kwargs)
-        form = ConfirmationForm(request.POST)
-
-        if form.is_valid():
-            logger.debug("Form validation was successful")
-
-            try:
-                obj.delete()
-            except ProtectedError as e:
-                logger.info("Caught ProtectedError while attempting to delete object")
-                handle_protectederror([obj], request, e)
-                return redirect(obj.get_absolute_url())
-
-            msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
-            logger.info(msg)
-            messages.success(request, msg)
-
-            return_url = form.cleaned_data.get('return_url')
-            if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
-                return redirect(return_url)
-            else:
-                return redirect(self.get_return_url(request, obj))
-
-        else:
-            logger.debug("Form validation failed")
-
-        return render(request, self.template_name, {
-            'obj': obj,
-            'form': form,
-            'obj_type': self.queryset.model._meta.verbose_name,
-            'return_url': self.get_return_url(request, obj),
-        })
-
-
-class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    Create new objects in bulk.
-
-    queryset: Base queryset for the objects being created
-    form: Form class which provides the `pattern` field
-    model_form: The ModelForm used to create individual objects
-    pattern_target: Name of the field to be evaluated as a pattern (if any)
-    template_name: The name of the template
-    """
-    queryset = None
-    form = None
-    model_form = None
-    pattern_target = ''
-    template_name = None
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'add')
-
-    def get(self, request):
-        # Set initial values for visible form fields from query args
-        initial = {}
-        for field in getattr(self.model_form._meta, 'fields', []):
-            if request.GET.get(field):
-                initial[field] = request.GET[field]
-
-        form = self.form()
-        model_form = self.model_form(initial=initial)
-
-        return render(request, self.template_name, {
-            'obj_type': self.model_form._meta.model._meta.verbose_name,
-            'form': form,
-            'model_form': model_form,
-            'return_url': self.get_return_url(request),
-        })
-
-    def post(self, request):
-        logger = logging.getLogger('netbox.views.BulkCreateView')
-        model = self.queryset.model
-        form = self.form(request.POST)
-        model_form = self.model_form(request.POST)
-
-        if form.is_valid():
-            logger.debug("Form validation was successful")
-            pattern = form.cleaned_data['pattern']
-            new_objs = []
-
-            try:
-                with transaction.atomic():
-
-                    # Create objects from the expanded. Abort the transaction on the first validation error.
-                    for value in pattern:
-
-                        # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
-                        # copy of the POST QueryDict so that we can update the target field value.
-                        model_form = self.model_form(request.POST.copy())
-                        model_form.data[self.pattern_target] = value
-
-                        # Validate each new object independently.
-                        if model_form.is_valid():
-                            obj = model_form.save()
-                            logger.debug(f"Created {obj} (PK: {obj.pk})")
-                            new_objs.append(obj)
-                        else:
-                            # Copy any errors on the pattern target field to the pattern form.
-                            errors = model_form.errors.as_data()
-                            if errors.get(self.pattern_target):
-                                form.add_error('pattern', errors[self.pattern_target])
-                            # Raise an IntegrityError to break the for loop and abort the transaction.
-                            raise IntegrityError()
-
-                    # Enforce object-level permissions
-                    if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
-                        raise ObjectDoesNotExist
-
-                    # If we make it to this point, validation has succeeded on all new objects.
-                    msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
-                    logger.info(msg)
-                    messages.success(request, msg)
-
-                    if '_addanother' in request.POST:
-                        return redirect(request.path)
-                    return redirect(self.get_return_url(request))
-
-            except IntegrityError:
-                pass
-
-            except ObjectDoesNotExist:
-                msg = "Object creation failed due to object-level permissions violation"
-                logger.debug(msg)
-                form.add_error(None, msg)
-
-        else:
-            logger.debug("Form validation failed")
-
-        return render(request, self.template_name, {
-            'form': form,
-            'model_form': model_form,
-            'obj_type': model._meta.verbose_name,
-            'return_url': self.get_return_url(request),
-        })
-
-
-class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    Import a single object (YAML or JSON format).
-
-    queryset: Base queryset for the objects being created
-    model_form: The ModelForm used to create individual objects
-    related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects
-    template_name: The name of the template
-    """
-    queryset = None
-    model_form = None
-    related_object_forms = dict()
-    template_name = 'utilities/obj_import.html'
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'add')
-
-    def get(self, request):
-        form = ImportForm()
-
-        return render(request, self.template_name, {
-            'form': form,
-            'obj_type': self.queryset.model._meta.verbose_name,
-            'return_url': self.get_return_url(request),
-        })
-
-    def post(self, request):
-        logger = logging.getLogger('netbox.views.ObjectImportView')
-        form = ImportForm(request.POST)
-
-        if form.is_valid():
-            logger.debug("Import form validation was successful")
-
-            # Initialize model form
-            data = form.cleaned_data['data']
-            model_form = self.model_form(data)
-            restrict_form_fields(model_form, request.user)
-
-            # Assign default values for any fields which were not specified. We have to do this manually because passing
-            # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not
-            # used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the
-            # applicable field defaults as needed prior to form validation.
-            for field_name, field in model_form.fields.items():
-                if field_name not in data and hasattr(field, 'initial'):
-                    model_form.data[field_name] = field.initial
-
-            if model_form.is_valid():
-
-                try:
-                    with transaction.atomic():
-
-                        # Save the primary object
-                        obj = model_form.save()
-
-                        # Enforce object-level permissions
-                        self.queryset.get(pk=obj.pk)
-
-                        logger.debug(f"Created {obj} (PK: {obj.pk})")
-
-                        # Iterate through the related object forms (if any), validating and saving each instance.
-                        for field_name, related_object_form in self.related_object_forms.items():
-                            logger.debug("Processing form for related objects: {related_object_form}")
-
-                            related_obj_pks = []
-                            for i, rel_obj_data in enumerate(data.get(field_name, list())):
-
-                                f = related_object_form(obj, rel_obj_data)
-
-                                for subfield_name, field in f.fields.items():
-                                    if subfield_name not in rel_obj_data and hasattr(field, 'initial'):
-                                        f.data[subfield_name] = field.initial
-
-                                if f.is_valid():
-                                    related_obj = f.save()
-                                    related_obj_pks.append(related_obj.pk)
-                                else:
-                                    # Replicate errors on the related object form to the primary form for display
-                                    for subfield_name, errors in f.errors.items():
-                                        for err in errors:
-                                            err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
-                                            model_form.add_error(None, err_msg)
-                                    raise AbortTransaction()
-
-                            # Enforce object-level permissions on related objects
-                            model = related_object_form.Meta.model
-                            if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks):
-                                raise ObjectDoesNotExist
-
-                except AbortTransaction:
-                    pass
-
-                except ObjectDoesNotExist:
-                    msg = "Object creation failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
-
-            if not model_form.errors:
-                logger.info(f"Import object {obj} (PK: {obj.pk})")
-                messages.success(request, mark_safe('Imported object: <a href="{}">{}</a>'.format(
-                    obj.get_absolute_url(), obj
-                )))
-
-                if '_addanother' in request.POST:
-                    return redirect(request.get_full_path())
-
-                return_url = form.cleaned_data.get('return_url')
-                if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
-                    return redirect(return_url)
-                else:
-                    return redirect(self.get_return_url(request, obj))
-
-            else:
-                logger.debug("Model form validation failed")
-
-                # Replicate model form errors for display
-                for field, errors in model_form.errors.items():
-                    for err in errors:
-                        if field == '__all__':
-                            form.add_error(None, err)
-                        else:
-                            form.add_error(None, "{}: {}".format(field, err))
-
-        else:
-            logger.debug("Import form validation failed")
-
-        return render(request, self.template_name, {
-            'form': form,
-            'obj_type': self.queryset.model._meta.verbose_name,
-            'return_url': self.get_return_url(request),
-        })
-
-
-class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    Import objects in bulk (CSV format).
-
-    queryset: Base queryset for the model
-    model_form: The form used to create each imported object
-    table: The django-tables2 Table used to render the list of imported objects
-    template_name: The name of the template
-    widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
-    """
-    queryset = None
-    model_form = None
-    table = None
-    template_name = 'utilities/obj_bulk_import.html'
-    widget_attrs = {}
-
-    def _import_form(self, *args, **kwargs):
-
-        class ImportForm(BootstrapMixin, Form):
-            csv = CSVDataField(
-                from_form=self.model_form,
-                widget=Textarea(attrs=self.widget_attrs)
-            )
-
-        return ImportForm(*args, **kwargs)
-
-    def _save_obj(self, obj_form, request):
-        """
-        Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data).
-        """
-        return obj_form.save()
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'add')
-
-    def get(self, request):
-
-        return render(request, self.template_name, {
-            'form': self._import_form(),
-            'fields': self.model_form().fields,
-            'obj_type': self.model_form._meta.model._meta.verbose_name,
-            'return_url': self.get_return_url(request),
-        })
-
-    def post(self, request):
-        logger = logging.getLogger('netbox.views.BulkImportView')
-        new_objs = []
-        form = self._import_form(request.POST)
-
-        if form.is_valid():
-            logger.debug("Form validation was successful")
-
-            try:
-                # Iterate through CSV data and bind each row to a new model form instance.
-                with transaction.atomic():
-                    headers, records = form.cleaned_data['csv']
-                    for row, data in enumerate(records, start=1):
-                        obj_form = self.model_form(data, headers=headers)
-                        restrict_form_fields(obj_form, request.user)
-
-                        if obj_form.is_valid():
-                            obj = self._save_obj(obj_form, request)
-                            new_objs.append(obj)
-                        else:
-                            for field, err in obj_form.errors.items():
-                                form.add_error('csv', "Row {} {}: {}".format(row, field, err[0]))
-                            raise ValidationError("")
-
-                    # Enforce object-level permissions
-                    if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
-                        raise ObjectDoesNotExist
-
-                # Compile a table containing the imported objects
-                obj_table = self.table(new_objs)
-
-                if new_objs:
-                    msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
-                    logger.info(msg)
-                    messages.success(request, msg)
-
-                    return render(request, "import_success.html", {
-                        'table': obj_table,
-                        'return_url': self.get_return_url(request),
-                    })
-
-            except ValidationError:
-                pass
-
-            except ObjectDoesNotExist:
-                msg = "Object import failed due to object-level permissions violation"
-                logger.debug(msg)
-                form.add_error(None, msg)
-
-        else:
-            logger.debug("Form validation failed")
-
-        return render(request, self.template_name, {
-            'form': form,
-            'fields': self.model_form().fields,
-            'obj_type': self.model_form._meta.model._meta.verbose_name,
-            'return_url': self.get_return_url(request),
-        })
-
-
-class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    Edit objects in bulk.
-
-    queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
-    filter: FilterSet to apply when deleting by QuerySet
-    table: The table used to display devices being edited
-    form: The form class used to edit objects in bulk
-    template_name: The name of the template
-    """
-    queryset = None
-    filterset = None
-    table = None
-    form = None
-    template_name = 'utilities/obj_bulk_edit.html'
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'change')
-
-    def get(self, request):
-        return redirect(self.get_return_url(request))
-
-    def post(self, request, **kwargs):
-        logger = logging.getLogger('netbox.views.BulkEditView')
-        model = self.queryset.model
-
-        # If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
-        if request.POST.get('_all') and self.filterset is not None:
-            pk_list = [
-                obj.pk for obj in self.filterset(request.GET, self.queryset.only('pk')).qs
-            ]
-        else:
-            pk_list = request.POST.getlist('pk')
-
-        if '_apply' in request.POST:
-            form = self.form(model, request.POST)
-            restrict_form_fields(form, request.user)
-
-            if form.is_valid():
-                logger.debug("Form validation was successful")
-                custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
-                standard_fields = [
-                    field for field in form.fields if field not in custom_fields + ['pk']
-                ]
-                nullified_fields = request.POST.getlist('_nullify')
-
-                try:
-
-                    with transaction.atomic():
-
-                        updated_objects = []
-                        for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
-
-                            # Update standard fields. If a field is listed in _nullify, delete its value.
-                            for name in standard_fields:
-
-                                try:
-                                    model_field = model._meta.get_field(name)
-                                except FieldDoesNotExist:
-                                    # This form field is used to modify a field rather than set its value directly
-                                    model_field = None
-
-                                # Handle nullification
-                                if name in form.nullable_fields and name in nullified_fields:
-                                    if isinstance(model_field, ManyToManyField):
-                                        getattr(obj, name).set([])
-                                    else:
-                                        setattr(obj, name, None if model_field.null else '')
-
-                                # ManyToManyFields
-                                elif isinstance(model_field, ManyToManyField):
-                                    if form.cleaned_data[name]:
-                                        getattr(obj, name).set(form.cleaned_data[name])
-                                # Normal fields
-                                elif form.cleaned_data[name] not in (None, ''):
-                                    setattr(obj, name, form.cleaned_data[name])
-
-                            # Update custom fields
-                            for name in custom_fields:
-                                if name in form.nullable_fields and name in nullified_fields:
-                                    obj.custom_field_data.pop(name, None)
-                                else:
-                                    obj.custom_field_data[name] = form.cleaned_data[name]
-
-                            obj.full_clean()
-                            obj.save()
-                            updated_objects.append(obj)
-                            logger.debug(f"Saved {obj} (PK: {obj.pk})")
-
-                            # Add/remove tags
-                            if form.cleaned_data.get('add_tags', None):
-                                obj.tags.add(*form.cleaned_data['add_tags'])
-                            if form.cleaned_data.get('remove_tags', None):
-                                obj.tags.remove(*form.cleaned_data['remove_tags'])
-
-                        # Enforce object-level permissions
-                        if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
-                            raise ObjectDoesNotExist
-
-                    if updated_objects:
-                        msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
-                        logger.info(msg)
-                        messages.success(self.request, msg)
-
-                    return redirect(self.get_return_url(request))
-
-                except ValidationError as e:
-                    messages.error(self.request, "{} failed validation: {}".format(obj, e))
-
-                except ObjectDoesNotExist:
-                    msg = "Object update failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
-
-            else:
-                logger.debug("Form validation failed")
-
-        else:
-            # Include the PK list as initial data for the form
-            initial_data = {'pk': pk_list}
-
-            # Check for other contextual data needed for the form. We avoid passing all of request.GET because the
-            # filter values will conflict with the bulk edit form fields.
-            # TODO: Find a better way to accomplish this
-            if 'device' in request.GET:
-                initial_data['device'] = request.GET.get('device')
-            elif 'device_type' in request.GET:
-                initial_data['device_type'] = request.GET.get('device_type')
-
-            form = self.form(model, initial=initial_data)
-            restrict_form_fields(form, request.user)
-
-        # Retrieve objects being edited
-        table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
-        if not table.rows:
-            messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
-            return redirect(self.get_return_url(request))
-
-        return render(request, self.template_name, {
-            'form': form,
-            'table': table,
-            'obj_type_plural': model._meta.verbose_name_plural,
-            'return_url': self.get_return_url(request),
-        })
-
-
-class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    An extendable view for renaming objects in bulk.
-    """
-    queryset = None
-    template_name = 'utilities/obj_bulk_rename.html'
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Create a new Form class from BulkRenameForm
-        class _Form(BulkRenameForm):
-            pk = ModelMultipleChoiceField(
-                queryset=self.queryset,
-                widget=MultipleHiddenInput()
-            )
-
-        self.form = _Form
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'change')
-
-    def post(self, request):
-        logger = logging.getLogger('netbox.views.BulkRenameView')
-
-        if '_preview' in request.POST or '_apply' in request.POST:
-            form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
-            selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
-
-            if form.is_valid():
-                try:
-                    with transaction.atomic():
-                        renamed_pks = []
-                        for obj in selected_objects:
-                            find = form.cleaned_data['find']
-                            replace = form.cleaned_data['replace']
-                            if form.cleaned_data['use_regex']:
-                                try:
-                                    obj.new_name = re.sub(find, replace, obj.name)
-                                # Catch regex group reference errors
-                                except re.error:
-                                    obj.new_name = obj.name
-                            else:
-                                obj.new_name = obj.name.replace(find, replace)
-                            renamed_pks.append(obj.pk)
-
-                        if '_apply' in request.POST:
-                            for obj in selected_objects:
-                                obj.name = obj.new_name
-                                obj.save()
-
-                            # Enforce constrained permissions
-                            if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
-                                raise ObjectDoesNotExist
-
-                            messages.success(request, "Renamed {} {}".format(
-                                len(selected_objects),
-                                self.queryset.model._meta.verbose_name_plural
-                            ))
-                            return redirect(self.get_return_url(request))
-
-                except ObjectDoesNotExist:
-                    msg = "Object update failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
-
-        else:
-            form = self.form(initial={'pk': request.POST.getlist('pk')})
-            selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
-
-        return render(request, self.template_name, {
-            'form': form,
-            'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
-            'selected_objects': selected_objects,
-            'return_url': self.get_return_url(request),
-        })
-
-
-class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    Delete objects in bulk.
-
-    queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
-    filter: FilterSet to apply when deleting by QuerySet
-    table: The table used to display devices being deleted
-    form: The form class used to delete objects in bulk
-    template_name: The name of the template
-    """
-    queryset = None
-    filterset = None
-    table = None
-    form = None
-    template_name = 'utilities/obj_bulk_delete.html'
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'delete')
-
-    def get(self, request):
-        return redirect(self.get_return_url(request))
-
-    def post(self, request, **kwargs):
-        logger = logging.getLogger('netbox.views.BulkDeleteView')
-        model = self.queryset.model
-
-        # Are we deleting *all* objects in the queryset or just a selected subset?
-        if request.POST.get('_all'):
-            if self.filterset is not None:
-                pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
-            else:
-                pk_list = model.objects.values_list('pk', flat=True)
-        else:
-            pk_list = [int(pk) for pk in request.POST.getlist('pk')]
-
-        form_cls = self.get_form()
-
-        if '_confirm' in request.POST:
-            form = form_cls(request.POST)
-            if form.is_valid():
-                logger.debug("Form validation was successful")
-
-                # Delete objects
-                queryset = self.queryset.filter(pk__in=pk_list)
-                try:
-                    deleted_count = queryset.delete()[1][model._meta.label]
-                except ProtectedError as e:
-                    logger.info("Caught ProtectedError while attempting to delete objects")
-                    handle_protectederror(queryset, request, e)
-                    return redirect(self.get_return_url(request))
-
-                msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural)
-                logger.info(msg)
-                messages.success(request, msg)
-                return redirect(self.get_return_url(request))
-
-            else:
-                logger.debug("Form validation failed")
-
-        else:
-            form = form_cls(initial={
-                'pk': pk_list,
-                'return_url': self.get_return_url(request),
-            })
-
-        # Retrieve objects being deleted
-        table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
-        if not table.rows:
-            messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
-            return redirect(self.get_return_url(request))
-
-        return render(request, self.template_name, {
-            'form': form,
-            'obj_type_plural': model._meta.verbose_name_plural,
-            'table': table,
-            'return_url': self.get_return_url(request),
-        })
-
-    def get_form(self):
-        """
-        Provide a standard bulk delete form if none has been specified for the view
-        """
-        class BulkDeleteForm(ConfirmationForm):
-            pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
-
-        if self.form:
-            return self.form
-
-        return BulkDeleteForm
-
-
-#
-# Device/VirtualMachine components
-#
-
-# TODO: Replace with BulkCreateView
-class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
-    """
-    queryset = None
-    form = None
-    model_form = None
-    template_name = None
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'add')
-
-    def get(self, request):
-
-        form = self.form(initial=request.GET)
-
-        return render(request, self.template_name, {
-            'component_type': self.queryset.model._meta.verbose_name,
-            'form': form,
-            'return_url': self.get_return_url(request),
-        })
-
-    def post(self, request):
-        logger = logging.getLogger('netbox.views.ComponentCreateView')
-        form = self.form(request.POST, initial=request.GET)
-
-        if form.is_valid():
-
-            new_components = []
-            data = deepcopy(request.POST)
-
-            names = form.cleaned_data['name_pattern']
-            labels = form.cleaned_data.get('label_pattern')
-            for i, name in enumerate(names):
-                label = labels[i] if labels else None
-                # Initialize the individual component form
-                data['name'] = name
-                data['label'] = label
-                if hasattr(form, 'get_iterative_data'):
-                    data.update(form.get_iterative_data(i))
-                component_form = self.model_form(data)
-
-                if component_form.is_valid():
-                    new_components.append(component_form)
-                else:
-                    for field, errors in component_form.errors.as_data().items():
-                        # Assign errors on the child form's name/label field to name_pattern/label_pattern on the parent form
-                        if field == 'name':
-                            field = 'name_pattern'
-                        elif field == 'label':
-                            field = 'label_pattern'
-                        for e in errors:
-                            form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
-
-            if not form.errors:
-
-                try:
-
-                    with transaction.atomic():
-
-                        # Create the new components
-                        new_objs = []
-                        for component_form in new_components:
-                            obj = component_form.save()
-                            new_objs.append(obj)
-
-                        # Enforce object-level permissions
-                        if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
-                            raise ObjectDoesNotExist
-
-                    messages.success(request, "Added {} {}".format(
-                        len(new_components), self.queryset.model._meta.verbose_name_plural
-                    ))
-                    if '_addanother' in request.POST:
-                        return redirect(request.get_full_path())
-                    else:
-                        return redirect(self.get_return_url(request))
-
-                except ObjectDoesNotExist:
-                    msg = "Component creation failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
-
-        return render(request, self.template_name, {
-            'component_type': self.queryset.model._meta.verbose_name,
-            'form': form,
-            'return_url': self.get_return_url(request),
-        })
-
-
-class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
-    """
-    parent_model = None
-    parent_field = None
-    form = None
-    queryset = None
-    model_form = None
-    filterset = None
-    table = None
-    template_name = 'utilities/obj_bulk_add_component.html'
-
-    def get_required_permission(self):
-        return f'dcim.add_{self.queryset.model._meta.model_name}'
-
-    def post(self, request):
-        logger = logging.getLogger('netbox.views.BulkComponentCreateView')
-        parent_model_name = self.parent_model._meta.verbose_name_plural
-        model_name = self.queryset.model._meta.verbose_name_plural
-
-        # Are we editing *all* objects in the queryset or just a selected subset?
-        if request.POST.get('_all') and self.filterset is not None:
-            pk_list = [obj.pk for obj in self.filterset(request.GET, self.parent_model.objects.only('pk')).qs]
-        else:
-            pk_list = [int(pk) for pk in request.POST.getlist('pk')]
-
-        selected_objects = self.parent_model.objects.filter(pk__in=pk_list)
-        if not selected_objects:
-            messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
-            return redirect(self.get_return_url(request))
-        table = self.table(selected_objects)
-
-        if '_create' in request.POST:
-            form = self.form(request.POST)
-
-            if form.is_valid():
-                logger.debug("Form validation was successful")
-
-                new_components = []
-                data = deepcopy(form.cleaned_data)
-
-                try:
-                    with transaction.atomic():
-
-                        for obj in data['pk']:
-
-                            names = data['name_pattern']
-                            labels = data['label_pattern'] if 'label_pattern' in data else None
-                            for i, name in enumerate(names):
-                                label = labels[i] if labels else None
-
-                                component_data = {
-                                    self.parent_field: obj.pk,
-                                    'name': name,
-                                    'label': label
-                                }
-                                component_data.update(data)
-                                component_form = self.model_form(component_data)
-                                if component_form.is_valid():
-                                    instance = component_form.save()
-                                    logger.debug(f"Created {instance} on {instance.parent}")
-                                    new_components.append(instance)
-                                else:
-                                    for field, errors in component_form.errors.as_data().items():
-                                        for e in errors:
-                                            form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
-
-                        # Enforce object-level permissions
-                        if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
-                            raise ObjectDoesNotExist
-
-                except IntegrityError:
-                    pass
-
-                except ObjectDoesNotExist:
-                    msg = "Component creation failed due to object-level permissions violation"
-                    logger.debug(msg)
-                    form.add_error(None, msg)
-
-                if not form.errors:
-                    msg = "Added {} {} to {} {}.".format(
-                        len(new_components),
-                        model_name,
-                        len(form.cleaned_data['pk']),
-                        parent_model_name
-                    )
-                    logger.info(msg)
-                    messages.success(request, msg)
-
-                    return redirect(self.get_return_url(request))
-
-            else:
-                logger.debug("Form validation failed")
-
-        else:
-            form = self.form(initial={'pk': pk_list})
-
-        return render(request, self.template_name, {
-            'form': form,
-            'parent_model_name': parent_model_name,
-            'model_name': model_name,
-            'table': table,
-            'return_url': self.get_return_url(request),
-        })
-
-
-@requires_csrf_token
-def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
-    """
-    Custom 500 handler to provide additional context when rendering 500.html.
-    """
-    try:
-        template = loader.get_template(template_name)
-    except TemplateDoesNotExist:
-        return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
-    type_, error, traceback = sys.exc_info()
-
-    return HttpResponseServerError(template.render({
-        'error': error,
-        'exception': str(type_),
-        'netbox_version': settings.VERSION,
-        'python_version': platform.python_version(),
-    }))

+ 37 - 40
netbox/virtualization/views.py

@@ -9,12 +9,9 @@ from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from ipam.models import IPAddress, Service
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
+from netbox.views import generic
 from secrets.models import Secret
 from utilities.utils import get_subquery
-from utilities.views import (
-    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
-    ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
-)
 from . import filters, forms, tables
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
@@ -23,27 +20,27 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf
 # Cluster types
 #
 
-class ClusterTypeListView(ObjectListView):
+class ClusterTypeListView(generic.ObjectListView):
     queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterType._meta.ordering)
     table = tables.ClusterTypeTable
 
 
-class ClusterTypeEditView(ObjectEditView):
+class ClusterTypeEditView(generic.ObjectEditView):
     queryset = ClusterType.objects.all()
     model_form = forms.ClusterTypeForm
 
 
-class ClusterTypeDeleteView(ObjectDeleteView):
+class ClusterTypeDeleteView(generic.ObjectDeleteView):
     queryset = ClusterType.objects.all()
 
 
-class ClusterTypeBulkImportView(BulkImportView):
+class ClusterTypeBulkImportView(generic.BulkImportView):
     queryset = ClusterType.objects.all()
     model_form = forms.ClusterTypeCSVForm
     table = tables.ClusterTypeTable
 
 
-class ClusterTypeBulkDeleteView(BulkDeleteView):
+class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterType._meta.ordering)
     table = tables.ClusterTypeTable
 
@@ -52,27 +49,27 @@ class ClusterTypeBulkDeleteView(BulkDeleteView):
 # Cluster groups
 #
 
-class ClusterGroupListView(ObjectListView):
+class ClusterGroupListView(generic.ObjectListView):
     queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterGroup._meta.ordering)
     table = tables.ClusterGroupTable
 
 
-class ClusterGroupEditView(ObjectEditView):
+class ClusterGroupEditView(generic.ObjectEditView):
     queryset = ClusterGroup.objects.all()
     model_form = forms.ClusterGroupForm
 
 
-class ClusterGroupDeleteView(ObjectDeleteView):
+class ClusterGroupDeleteView(generic.ObjectDeleteView):
     queryset = ClusterGroup.objects.all()
 
 
-class ClusterGroupBulkImportView(BulkImportView):
+class ClusterGroupBulkImportView(generic.BulkImportView):
     queryset = ClusterGroup.objects.all()
     model_form = forms.ClusterGroupCSVForm
     table = tables.ClusterGroupTable
 
 
-class ClusterGroupBulkDeleteView(BulkDeleteView):
+class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')).order_by(*ClusterGroup._meta.ordering)
     table = tables.ClusterGroupTable
 
@@ -81,7 +78,7 @@ class ClusterGroupBulkDeleteView(BulkDeleteView):
 # Clusters
 #
 
-class ClusterListView(ObjectListView):
+class ClusterListView(generic.ObjectListView):
     permission_required = 'virtualization.view_cluster'
     queryset = Cluster.objects.annotate(
         device_count=get_subquery(Device, 'cluster'),
@@ -92,7 +89,7 @@ class ClusterListView(ObjectListView):
     filterset_form = forms.ClusterFilterForm
 
 
-class ClusterView(ObjectView):
+class ClusterView(generic.ObjectView):
     queryset = Cluster.objects.all()
 
     def get(self, request, pk):
@@ -114,36 +111,36 @@ class ClusterView(ObjectView):
         })
 
 
-class ClusterEditView(ObjectEditView):
+class ClusterEditView(generic.ObjectEditView):
     template_name = 'virtualization/cluster_edit.html'
     queryset = Cluster.objects.all()
     model_form = forms.ClusterForm
 
 
-class ClusterDeleteView(ObjectDeleteView):
+class ClusterDeleteView(generic.ObjectDeleteView):
     queryset = Cluster.objects.all()
 
 
-class ClusterBulkImportView(BulkImportView):
+class ClusterBulkImportView(generic.BulkImportView):
     queryset = Cluster.objects.all()
     model_form = forms.ClusterCSVForm
     table = tables.ClusterTable
 
 
-class ClusterBulkEditView(BulkEditView):
+class ClusterBulkEditView(generic.BulkEditView):
     queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
     filterset = filters.ClusterFilterSet
     table = tables.ClusterTable
     form = forms.ClusterBulkEditForm
 
 
-class ClusterBulkDeleteView(BulkDeleteView):
+class ClusterBulkDeleteView(generic.BulkDeleteView):
     queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
     filterset = filters.ClusterFilterSet
     table = tables.ClusterTable
 
 
-class ClusterAddDevicesView(ObjectEditView):
+class ClusterAddDevicesView(generic.ObjectEditView):
     queryset = Cluster.objects.all()
     form = forms.ClusterAddDevicesForm
     template_name = 'virtualization/cluster_add_devices.html'
@@ -184,7 +181,7 @@ class ClusterAddDevicesView(ObjectEditView):
         })
 
 
-class ClusterRemoveDevicesView(ObjectEditView):
+class ClusterRemoveDevicesView(generic.ObjectEditView):
     queryset = Cluster.objects.all()
     form = forms.ClusterRemoveDevicesForm
     template_name = 'utilities/obj_bulk_remove.html'
@@ -229,7 +226,7 @@ class ClusterRemoveDevicesView(ObjectEditView):
 # Virtual machines
 #
 
-class VirtualMachineListView(ObjectListView):
+class VirtualMachineListView(generic.ObjectListView):
     queryset = VirtualMachine.objects.all()
     filterset = filters.VirtualMachineFilterSet
     filterset_form = forms.VirtualMachineFilterForm
@@ -237,7 +234,7 @@ class VirtualMachineListView(ObjectListView):
     template_name = 'virtualization/virtualmachine_list.html'
 
 
-class VirtualMachineView(ObjectView):
+class VirtualMachineView(generic.ObjectView):
     queryset = VirtualMachine.objects.prefetch_related('tenant__group')
 
     def get(self, request, pk):
@@ -277,30 +274,30 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
     base_template = 'virtualization/virtualmachine.html'
 
 
-class VirtualMachineEditView(ObjectEditView):
+class VirtualMachineEditView(generic.ObjectEditView):
     queryset = VirtualMachine.objects.all()
     model_form = forms.VirtualMachineForm
     template_name = 'virtualization/virtualmachine_edit.html'
 
 
-class VirtualMachineDeleteView(ObjectDeleteView):
+class VirtualMachineDeleteView(generic.ObjectDeleteView):
     queryset = VirtualMachine.objects.all()
 
 
-class VirtualMachineBulkImportView(BulkImportView):
+class VirtualMachineBulkImportView(generic.BulkImportView):
     queryset = VirtualMachine.objects.all()
     model_form = forms.VirtualMachineCSVForm
     table = tables.VirtualMachineTable
 
 
-class VirtualMachineBulkEditView(BulkEditView):
+class VirtualMachineBulkEditView(generic.BulkEditView):
     queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
     filterset = filters.VirtualMachineFilterSet
     table = tables.VirtualMachineTable
     form = forms.VirtualMachineBulkEditForm
 
 
-class VirtualMachineBulkDeleteView(BulkDeleteView):
+class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
     queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
     filterset = filters.VirtualMachineFilterSet
     table = tables.VirtualMachineTable
@@ -310,7 +307,7 @@ class VirtualMachineBulkDeleteView(BulkDeleteView):
 # VM interfaces
 #
 
-class VMInterfaceListView(ObjectListView):
+class VMInterfaceListView(generic.ObjectListView):
     queryset = VMInterface.objects.all()
     filterset = filters.VMInterfaceFilterSet
     filterset_form = forms.VMInterfaceFilterForm
@@ -318,7 +315,7 @@ class VMInterfaceListView(ObjectListView):
     action_buttons = ('export',)
 
 
-class VMInterfaceView(ObjectView):
+class VMInterfaceView(generic.ObjectView):
     queryset = VMInterface.objects.all()
 
     def get(self, request, pk):
@@ -353,41 +350,41 @@ class VMInterfaceView(ObjectView):
 
 
 # TODO: This should not use ComponentCreateView
-class VMInterfaceCreateView(ComponentCreateView):
+class VMInterfaceCreateView(generic.ComponentCreateView):
     queryset = VMInterface.objects.all()
     form = forms.VMInterfaceCreateForm
     model_form = forms.VMInterfaceForm
     template_name = 'virtualization/virtualmachine_component_add.html'
 
 
-class VMInterfaceEditView(ObjectEditView):
+class VMInterfaceEditView(generic.ObjectEditView):
     queryset = VMInterface.objects.all()
     model_form = forms.VMInterfaceForm
     template_name = 'virtualization/vminterface_edit.html'
 
 
-class VMInterfaceDeleteView(ObjectDeleteView):
+class VMInterfaceDeleteView(generic.ObjectDeleteView):
     queryset = VMInterface.objects.all()
 
 
-class VMInterfaceBulkImportView(BulkImportView):
+class VMInterfaceBulkImportView(generic.BulkImportView):
     queryset = VMInterface.objects.all()
     model_form = forms.VMInterfaceCSVForm
     table = tables.VMInterfaceTable
 
 
-class VMInterfaceBulkEditView(BulkEditView):
+class VMInterfaceBulkEditView(generic.BulkEditView):
     queryset = VMInterface.objects.all()
     table = tables.VMInterfaceTable
     form = forms.VMInterfaceBulkEditForm
 
 
-class VMInterfaceBulkRenameView(BulkRenameView):
+class VMInterfaceBulkRenameView(generic.BulkRenameView):
     queryset = VMInterface.objects.all()
     form = forms.VMInterfaceBulkRenameForm
 
 
-class VMInterfaceBulkDeleteView(BulkDeleteView):
+class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
     queryset = VMInterface.objects.all()
     table = tables.VMInterfaceTable
 
@@ -396,7 +393,7 @@ class VMInterfaceBulkDeleteView(BulkDeleteView):
 # Bulk Device component creation
 #
 
-class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView):
+class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
     parent_model = VirtualMachine
     parent_field = 'virtual_machine'
     form = forms.VMInterfaceBulkCreateForm

Некоторые файлы не были показаны из-за большого количества измененных файлов