Repository: netbox-community/netbox

Issue:
Support the use of NetBox's generic views by plugins
### NetBox version

v3.1.5

### Feature type

New functionality

### Proposed functionality

Formally support and document the use of NetBox's generic view classes by plugins. These are to include initially:

- ObjectView
- ObjectEditView
- ObjectDeleteView
- ObjectListView
- BulkImportView
- BulkEditView
- BulkDeleteView

Additional views may also be added or created depending on feedback from plugin authors.

Most of these classes likely must undergo so amount of refactoring and formalization to ensure a degree of maturity suitable for the inclusion in a public framework.

### Use case

Much like Django's own [generic views](https://docs.djangoproject.com/en/stable/ref/class-based-views/), use of these views enables plugin authors to avoid writing a substantial amount of boilerplate code.

### Database changes

_No response_

### External dependencies

_No response_


Discussion:
Hey,

we actually implemented this functionality within a quite hacky plugin our my employer. We could contribute an initial implementation. Is this the desired approach?

Best Regards, 
  Johann
@johannwagner I think for the most part this amounts to "hardening" the existing view classes to make them suitable for use outside NetBox core. I'd be interested to see what changes you made. Is there a way you could share a summary, short of drafting a whole PR?
Mostly it's fixing up the url names because of the plugin prefix. The rest "just works" but lacks a lot of documentation, because the public use wasn't intended yet. 
@johannwagner Roger. I know @DanSheps was working on that recently in the [`6221-pluginviews` branch](https://github.com/netbox-community/netbox/tree/6221-pluginviews); does that look similar to your tweaks?
Yup, we have similar patches, I just looked it up. 
We have patches for:
- `viewname` and `validated_viewname` template tags for the plugin prefix
- ButtonsColumn also to implement the plugin prefix.

Those patches enables us to write plugin with newly introduced models with almost no boilerplate. Most boilerplate code comes from the `<modelname>.html` of the generic object view, which could be eliminated by some finer block overrides.
I also would like to see a better method of using different tabs within a detailed view, this is kinda hacky right now. 

Resolving patch:
diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py
new file mode 100644
--- /dev/null
+++ b/netbox/netbox/views/generic/base.py
@@ -0,0 +1,58 @@
+from django.shortcuts import get_object_or_404
+from django.views.generic import View
+
+from utilities.views import ObjectPermissionRequiredMixin
+
+
+class BaseObjectView(ObjectPermissionRequiredMixin, View):
+    """
+    Base view class for reusable generic views.
+
+    Attributes:
+        queryset: Django QuerySet from which the object(s) will be fetched
+        template_name: The name of the HTML template file to render
+    """
+    queryset = None
+    template_name = None
+
+    def get_object(self, **kwargs):
+        """
+        Return the object being viewed or modified. The object is identified by an arbitrary set of keyword arguments
+        gleaned from the URL, which are passed to `get_object_or_404()`. (Typically, only a primary key is needed.)
+
+        If no matching object is found, return a 404 response.
+        """
+        return get_object_or_404(self.queryset, **kwargs)
+
+    def get_extra_context(self, request, instance):
+        """
+        Return any additional context data to include when rendering the template.
+
+        Args:
+            request: The current request
+            instance: The object being viewed
+        """
+        return {}
+
+
+class BaseMultiObjectView(ObjectPermissionRequiredMixin, View):
+    """
+    Base view class for reusable generic views.
+
+    Attributes:
+        queryset: Django QuerySet from which the object(s) will be fetched
+        table: The django-tables2 Table class used to render the objects list
+        template_name: The name of the HTML template file to render
+    """
+    queryset = None
+    table = None
+    template_name = None
+
+    def get_extra_context(self, request):
+        """
+        Return any additional context data to include when rendering the template.
+
+        Args:
+            request: The current request
+        """
+        return {}
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -3,21 +3,27 @@
 from copy import deepcopy
 
 from django.contrib import messages
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
 from django.db.models import ManyToManyField, ProtectedError
 from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
-from django.shortcuts import redirect, render
-from django.views.generic import View
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect, render
+from django_tables2.export import TableExport
 
+from extras.models import ExportTemplate
 from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import PermissionsViolation
 from utilities.forms import (
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields,
 )
+from utilities.htmx import is_htmx
 from utilities.permissions import get_permission_for_model
-from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
+from utilities.tables import configure_table
+from utilities.views import GetReturnURLMixin
+from .base import BaseMultiObjectView
 
 __all__ = (
     'BulkComponentCreateView',
@@ -26,24 +32,174 @@
     'BulkEditView',
     'BulkImportView',
     'BulkRenameView',
+    'ObjectListView',
 )
 
 
-class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class ObjectListView(BaseMultiObjectView):
+    """
+    Display multiple objects, all of the same type, as a table.
+
+    Attributes:
+        filterset: A django-filter FilterSet that is applied to the queryset
+        filterset_form: The form class used to render filter options
+        action_buttons: A list of buttons to include at the top of the page
+    """
+    template_name = 'generic/object_list.html'
+    filterset = None
+    filterset_form = None
+    action_buttons = ('add', 'import', 'export')
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'view')
+
+    def get_table(self, request, permissions):
+        """
+        Return the django-tables2 Table instance to be used for rendering the objects list.
+
+        Args:
+            request: The current request
+            permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating
+                whether the user has each
+        """
+        table = self.table(self.queryset, user=request.user)
+        if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
+            table.columns.show('pk')
+
+        return table
+
+    #
+    # Export methods
+    #
+
+    def export_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 export_table(self, table, columns=None, filename=None):
+        """
+        Export all table data in CSV format.
+
+        Args:
+            table: The Table instance to export
+            columns: A list of specific columns to include. If None, all columns will be exported.
+            filename: The name of the file attachment sent to the client. If None, will be determined automatically
+                from the queryset model name.
+        """
+        exclude_columns = {'pk', 'actions'}
+        if columns:
+            all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns]
+            exclude_columns.update({
+                col for col in all_columns if col not in columns
+            })
+        exporter = TableExport(
+            export_format=TableExport.CSV,
+            table=table,
+            exclude_columns=exclude_columns
+        )
+        return exporter.response(
+            filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
+        )
+
+    def export_template(self, template, request):
+        """
+        Render an ExportTemplate using the current queryset.
+
+        Args:
+            template: ExportTemplate instance
+            request: The current request
+        """
+        try:
+            return template.render_to_response(self.queryset)
+        except Exception as e:
+            messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
+            return redirect(request.path)
+
+    #
+    # Request handlers
+    #
+
+    def get(self, request):
+        """
+        GET request handler.
+
+        Args:
+            request: The current 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
+
+        # 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)
+
+        if 'export' in request.GET:
+
+            # Export the current table view
+            if request.GET['export'] == 'table':
+                table = self.get_table(request, permissions)
+                columns = [name for name, _ in table.selected_columns]
+                return self.export_table(table, columns)
+
+            # Render an ExportTemplate
+            elif request.GET['export']:
+                template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
+                return self.export_template(template, request)
+
+            # Check for YAML export support on the model
+            elif hasattr(model, 'to_yaml'):
+                response = HttpResponse(self.export_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 default table/YAML export
+            else:
+                table = self.get_table(request, permissions)
+                return self.export_table(table)
+
+        # Render the objects table
+        table = self.get_table(request, permissions)
+        configure_table(table, request)
+
+        # If this is an HTMX request, return only the rendered table HTML
+        if is_htmx(request):
+            return render(request, 'htmx/table.html', {
+                'table': table,
+            })
+
+        context = {
+            'content_type': content_type,
+            'table': table,
+            'permissions': permissions,
+            'action_buttons': self.action_buttons,
+            'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
+            **self.get_extra_context(request),
+        }
+
+        return render(request, self.template_name, context)
+
+
+class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
     """
     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')
@@ -73,6 +229,10 @@ def _create_objects(self, form, request):
 
         return new_objects
 
+    #
+    # Request handlers
+    #
+
     def get(self, request):
         # Set initial values for visible form fields from query args
         initial = {}
@@ -88,6 +248,7 @@ def get(self, request):
             'form': form,
             'model_form': model_form,
             'return_url': self.get_return_url(request),
+            **self.get_extra_context(request),
         })
 
     def post(self, request):
@@ -132,31 +293,25 @@ def post(self, request):
             'model_form': model_form,
             'obj_type': model._meta.verbose_name,
             'return_url': self.get_return_url(request),
+            **self.get_extra_context(request),
         })
 
 
-class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
     """
     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)
+    Attributes:
+        model_form: The form used to create each imported object
     """
-    queryset = None
-    model_form = None
-    table = None
     template_name = 'generic/object_bulk_import.html'
-    widget_attrs = {}
+    model_form = None
 
     def _import_form(self, *args, **kwargs):
 
         class ImportForm(BootstrapMixin, Form):
             csv = CSVDataField(
-                from_form=self.model_form,
-                widget=Textarea(attrs=self.widget_attrs)
+                from_form=self.model_form
             )
             csv_file = CSVFileField(
                 label="CSV file",
@@ -207,6 +362,10 @@ def _save_obj(self, obj_form, request):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'add')
 
+    #
+    # Request handlers
+    #
+
     def get(self, request):
 
         return render(request, self.template_name, {
@@ -214,6 +373,7 @@ def get(self, request):
             'fields': self.model_form().fields,
             'obj_type': self.model_form._meta.model._meta.verbose_name,
             'return_url': self.get_return_url(request),
+            **self.get_extra_context(request),
         })
 
     def post(self, request):
@@ -262,24 +422,21 @@ def post(self, request):
             'fields': self.model_form().fields,
             'obj_type': self.model_form._meta.model._meta.verbose_name,
             'return_url': self.get_return_url(request),
+            **self.get_extra_context(request),
         })
 
 
-class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
     """
     Edit objects in bulk.
 
-    queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
-    filterset: 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
+    Attributes:
+        filterset: FilterSet to apply when deleting by QuerySet
+        form: The form class used to edit objects in bulk
     """
-    queryset = None
+    template_name = 'generic/object_bulk_edit.html'
     filterset = None
-    table = None
     form = None
-    template_name = 'generic/object_bulk_edit.html'
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'change')
@@ -341,6 +498,10 @@ def _update_objects(self, form, request):
 
         return updated_objects
 
+    #
+    # Request handlers
+    #
+
     def get(self, request):
         return redirect(self.get_return_url(request))
 
@@ -419,17 +580,14 @@ def post(self, request, **kwargs):
             'table': table,
             'obj_type_plural': model._meta.verbose_name_plural,
             'return_url': self.get_return_url(request),
+            **self.get_extra_context(request),
         })
 
 
-class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
     """
     An extendable view for renaming objects in bulk.
-
-    queryset: QuerySet of objects being renamed
-    template_name: The name of the template
     """
-    queryset = None
     template_name = 'generic/object_bulk_rename.html'
 
     def __init__(self, *args, **kwargs):
@@ -513,25 +671,38 @@ def post(self, request):
         })
 
 
-class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
     """
     Delete objects in bulk.
 
-    queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
     filterset: 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
+    template_name = 'generic/object_bulk_delete.html'
     filterset = None
     table = None
     form = None
-    template_name = 'generic/object_bulk_delete.html'
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'delete')
 
+    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
+
+    #
+    # Request handlers
+    #
+
     def get(self, request):
         return redirect(self.get_return_url(request))
 
@@ -594,37 +765,25 @@ def post(self, request, **kwargs):
             'obj_type_plural': model._meta.verbose_name_plural,
             'table': table,
             'return_url': self.get_return_url(request),
+            **self.get_extra_context(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
 #
 
-class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
     """
+    template_name = 'generic/object_bulk_add_component.html'
     parent_model = None
     parent_field = None
     form = None
-    queryset = None
     model_form = None
     filterset = None
     table = None
-    template_name = 'generic/object_bulk_add_component.html'
 
     def get_required_permission(self):
         return f'dcim.add_{self.queryset.model._meta.model_name}'
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -2,21 +2,16 @@
 from copy import deepcopy
 
 from django.contrib import messages
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
 from django.db.models import ProtectedError
 from django.forms.widgets import HiddenInput
-from django.http import HttpResponse
-from django.shortcuts import get_object_or_404, redirect, render
+from django.shortcuts import redirect, render
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.http import is_safe_url
 from django.utils.safestring import mark_safe
-from django.views.generic import View
-from django_tables2.export import TableExport
 
-from extras.models import ExportTemplate
 from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortTransaction, PermissionsViolation
@@ -25,7 +20,8 @@
 from utilities.permissions import get_permission_for_model
 from utilities.tables import configure_table
 from utilities.utils import normalize_querydict, prepare_cloned_fields
-from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
+from utilities.views import GetReturnURLMixin
+from .base import BaseObjectView
 
 __all__ = (
     'ComponentCreateView',
@@ -33,49 +29,41 @@
     'ObjectDeleteView',
     'ObjectEditView',
     'ObjectImportView',
-    'ObjectListView',
     'ObjectView',
 )
 
 
-class ObjectView(ObjectPermissionRequiredMixin, View):
+class ObjectView(BaseObjectView):
     """
     Retrieve a single object for display.
 
-    queryset: The base queryset for retrieving the object
-    template_name: Name of the template to use
+    Note: If `template_name` is not specified, it will be determined automatically based on the queryset model.
     """
-    queryset = None
-    template_name = 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.
+        Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset
+        model's `app_label` and `model_name`.
         """
         if self.template_name is not None:
             return self.template_name
         model_opts = self.queryset.model._meta
         return f'{model_opts.app_label}/{model_opts.model_name}.html'
 
-    def get_extra_context(self, request, instance):
-        """
-        Return any additional context data for the template.
+    #
+    # Request handlers
+    #
 
-        :param request: The current request
-        :param instance: The object being viewed
+    def get(self, request, **kwargs):
         """
-        return {}
+        GET request handler. `*args` and `**kwargs` are passed to identify the object being queried.
 
-    def get(self, request, *args, **kwargs):
+        Args:
+            request: The current request
         """
-        GET request handler. *args and **kwargs are passed to identify the object being queried.
-
-        :param request: The current request
-        """
-        instance = get_object_or_404(self.queryset, **kwargs)
+        instance = self.get_object(**kwargs)
 
         return render(request, self.get_template_name(), {
             'object': instance,
@@ -87,15 +75,12 @@ class ObjectChildrenView(ObjectView):
     """
     Display a table of child objects associated with the parent object.
 
-    queryset: The base queryset for retrieving the *parent* object
-    table: Table class used to render child objects list
-    template_name: Name of the template to use
+    Attributes:
+        table: Table class used to render child objects list
     """
-    queryset = None
     child_model = None
     table = None
     filterset = None
-    template_name = None
 
     def get_children(self, request, parent):
         """
@@ -110,17 +95,22 @@ def prep_table_data(self, request, queryset, parent):
         """
         Provides a hook for subclassed views to modify data before initializing the table.
 
-        :param request: The current request
-        :param queryset: The filtered queryset of child objects
-        :param parent: The parent object
+        Args:
+            request: The current request
+            queryset: The filtered queryset of child objects
+            parent: The parent object
         """
         return queryset
 
+    #
+    # Request handlers
+    #
+
     def get(self, request, *args, **kwargs):
         """
         GET handler for rendering child objects.
         """
-        instance = get_object_or_404(self.queryset, **kwargs)
+        instance = self.get_object(**kwargs)
         child_objects = self.get_children(request, instance)
 
         if self.filterset:
@@ -152,171 +142,17 @@ def get(self, request, *args, **kwargs):
         })
 
 
-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.
-    filterset: A django-filter FilterSet that is applied to the queryset
-    filterset_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
-    action_buttons: A list of buttons to include at the top of the page
-    """
-    queryset = None
-    filterset = None
-    filterset_form = None
-    table = None
-    template_name = 'generic/object_list.html'
-    action_buttons = ('add', 'import', 'export')
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'view')
-
-    def get_table(self, request, permissions):
-        """
-        Return the django-tables2 Table instance to be used for rendering the objects list.
-
-        :param request: The current request
-        :param permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating
-            whether the user has each
-        """
-        table = self.table(self.queryset, user=request.user)
-        if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
-            table.columns.show('pk')
-
-        return table
-
-    def export_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 export_table(self, table, columns=None):
-        """
-        Export all table data in CSV format.
-
-        :param table: The Table instance to export
-        :param columns: A list of specific columns to include. If not specified, all columns will be exported.
-        """
-        exclude_columns = {'pk', 'actions'}
-        if columns:
-            all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns]
-            exclude_columns.update({
-                col for col in all_columns if col not in columns
-            })
-        exporter = TableExport(
-            export_format=TableExport.CSV,
-            table=table,
-            exclude_columns=exclude_columns
-        )
-        return exporter.response(
-            filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
-        )
-
-    def export_template(self, template, request):
-        """
-        Render an ExportTemplate using the current queryset.
-
-        :param template: ExportTemplate instance
-        :param request: The current request
-        """
-        try:
-            return template.render_to_response(self.queryset)
-        except Exception as e:
-            messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
-            return redirect(request.path)
-
-    def get_extra_context(self, request):
-        """
-        Return any additional context data for the template.
-
-        :param request: The current request
-        """
-        return {}
-
-    def get(self, request):
-        """
-        GET request handler.
-
-        :param request: The current 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
-
-        # 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)
-
-        if 'export' in request.GET:
-
-            # Export the current table view
-            if request.GET['export'] == 'table':
-                table = self.get_table(request, permissions)
-                columns = [name for name, _ in table.selected_columns]
-                return self.export_table(table, columns)
-
-            # Render an ExportTemplate
-            elif request.GET['export']:
-                template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
-                return self.export_template(template, request)
-
-            # Check for YAML export support on the model
-            elif hasattr(model, 'to_yaml'):
-                response = HttpResponse(self.export_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 default table/YAML export
-            else:
-                table = self.get_table(request, permissions)
-                return self.export_table(table)
-
-        # Render the objects table
-        table = self.get_table(request, permissions)
-        configure_table(table, request)
-
-        # If this is an HTMX request, return only the rendered table HTML
-        if is_htmx(request):
-            return render(request, 'htmx/table.html', {
-                'table': table,
-            })
-
-        context = {
-            'content_type': content_type,
-            'table': table,
-            'permissions': permissions,
-            'action_buttons': self.action_buttons,
-            'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
-        }
-        context.update(self.get_extra_context(request))
-
-        return render(request, self.template_name, context)
-
-
-class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class ObjectImportView(GetReturnURLMixin, BaseObjectView):
     """
     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
+    Attributes:
+        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
     """
-    queryset = None
+    template_name = 'generic/object_import.html'
     model_form = None
     related_object_forms = dict()
-    template_name = 'generic/object_import.html'
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'add')
@@ -367,6 +203,10 @@ def _create_object(self, model_form):
 
         return obj
 
+    #
+    # Request handlers
+    #
+
     def get(self, request):
         form = ImportForm()
 
@@ -445,17 +285,21 @@ def post(self, request):
         })
 
 
-class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class ObjectEditView(GetReturnURLMixin, BaseObjectView):
     """
     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
+    Attributes:
+        model_form: The form used to create or edit the object
     """
-    queryset = None
-    model_form = None
     template_name = 'generic/object_edit.html'
+    model_form = None
+
+    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_required_permission(self):
         # self._permission_action is set by dispatch() to either "add" or "change" depending on whether
@@ -464,42 +308,36 @@ def get_required_permission(self):
 
     def get_object(self, **kwargs):
         """
-        Return an instance for editing. If a PK has been specified, this will be an existing object.
-
-        :param kwargs: URL path kwargs
+        Return an object for editing. If no keyword arguments have been specified, this will be a new instance.
         """
-        if 'pk' in kwargs:
-            obj = get_object_or_404(self.queryset, **kwargs)
-            # Take a snapshot of change-logged models
-            if hasattr(obj, 'snapshot'):
-                obj.snapshot()
-            return obj
-
-        return self.queryset.model()
+        if not kwargs:
+            # We're creating a new object
+            return self.queryset.model()
+        return super().get_object(**kwargs)
 
     def alter_object(self, obj, request, url_args, url_kwargs):
         """
         Provides a hook for views to modify an object before it is processed. For example, a parent object can be
         defined given some parameter from the request URL.
 
-        :param obj: The object being edited
-        :param request: The current request
-        :param url_args: URL path args
-        :param url_kwargs: URL path kwargs
+        Args:
+            obj: The object being edited
+            request: The current request
+            url_args: URL path args
+            url_kwargs: URL path kwargs
         """
         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)
+    #
+    # Request handlers
+    #
 
     def get(self, request, *args, **kwargs):
         """
         GET request handler.
 
-        :param request: The current request
+        Args:
+            request: The current request
         """
         obj = self.get_object(**kwargs)
         obj = self.alter_object(obj, request, args, kwargs)
@@ -513,16 +351,23 @@ def get(self, request, *args, **kwargs):
             'obj_type': self.queryset.model._meta.verbose_name,
             'form': form,
             'return_url': self.get_return_url(request, obj),
+            **self.get_extra_context(request, obj),
         })
 
     def post(self, request, *args, **kwargs):
         """
         POST request handler.
 
-        :param request: The current request
+        Args:
+            request: The current request
         """
         logger = logging.getLogger('netbox.views.ObjectEditView')
         obj = self.get_object(**kwargs)
+
+        # Take a snapshot for change logging (if editing an existing object)
+        if obj.pk and hasattr(obj, 'snapshot'):
+            obj.snapshot()
+
         obj = self.alter_object(obj, request, args, kwargs)
 
         form = self.model_form(
@@ -585,41 +430,29 @@ def post(self, request, *args, **kwargs):
             'obj_type': self.queryset.model._meta.verbose_name,
             'form': form,
             'return_url': self.get_return_url(request, obj),
+            **self.get_extra_context(request, obj),
         })
 
 
-class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
     """
     Delete a single object.
-
-    queryset: The base queryset for the object being deleted
-    template_name: The name of the template
     """
-    queryset = None
     template_name = 'generic/object_delete.html'
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'delete')
 
-    def get_object(self, **kwargs):
-        """
-        Return an instance for deletion. If a PK has been specified, this will be an existing object.
-
-        :param kwargs: URL path kwargs
-        """
-        obj = get_object_or_404(self.queryset, **kwargs)
-
-        # Take a snapshot of change-logged models
-        if hasattr(obj, 'snapshot'):
-            obj.snapshot()
-
-        return obj
+    #
+    # Request handlers
+    #
 
     def get(self, request, *args, **kwargs):
         """
         GET request handler.
 
-        :param request: The current request
+        Args:
+            request: The current request
         """
         obj = self.get_object(**kwargs)
         form = ConfirmationForm(initial=request.GET)
@@ -633,6 +466,7 @@ def get(self, request, *args, **kwargs):
                 'object_type': self.queryset.model._meta.verbose_name,
                 'form': form,
                 'form_url': form_url,
+                **self.get_extra_context(request, obj),
             })
 
         return render(request, self.template_name, {
@@ -640,18 +474,24 @@ def get(self, request, *args, **kwargs):
             'object_type': self.queryset.model._meta.verbose_name,
             'form': form,
             'return_url': self.get_return_url(request, obj),
+            **self.get_extra_context(request, obj),
         })
 
     def post(self, request, *args, **kwargs):
         """
         POST request handler.
 
-        :param request: The current request
+        Args:
+            request: The current request
         """
         logger = logging.getLogger('netbox.views.ObjectDeleteView')
         obj = self.get_object(**kwargs)
         form = ConfirmationForm(request.POST)
 
+        # Take a snapshot of change-logged models
+        if hasattr(obj, 'snapshot'):
+            obj.snapshot()
+
         if form.is_valid():
             logger.debug("Form validation was successful")
 
@@ -680,6 +520,7 @@ def post(self, request, *args, **kwargs):
             'object_type': self.queryset.model._meta.verbose_name,
             'form': form,
             'return_url': self.get_return_url(request, obj),
+            **self.get_extra_context(request, obj),
         })
 
 
@@ -687,14 +528,13 @@ def post(self, request, *args, **kwargs):
 # Device/VirtualMachine components
 #
 
-class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     """
-    queryset = None
+    template_name = 'dcim/component_create.html'
     form = None
     model_form = None
-    template_name = 'dcim/component_create.html'
     patterned_fields = ('name', 'label')
 
     def get_required_permission(self):


Test patch: