Преглед изворни кода

Merge pull request #8428 from netbox-community/8334-plugins-views

Closes #8334: Formally support use of generic views by plugins
Jeremy Stretch пре 4 година
родитељ
комит
05d4c127ee

+ 96 - 0
docs/plugins/development/generic-views.md

@@ -0,0 +1,96 @@
+# Generic Views
+
+NetBox provides several generic view classes to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
+
+| View Class | Description |
+|------------|-------------|
+| `ObjectView` | View a single object |
+| `ObjectEditView` | Create or edit a single object |
+| `ObjectDeleteView` | Delete a single object |
+| `ObjectListView` | View a list of objects |
+| `BulkImportView` | Import a set of new objects |
+| `BulkEditView` | Edit multiple objects |
+| `BulkDeleteView` | Delete multiple objects |
+
+!!! note
+    Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
+
+### Example Usage
+
+```python
+# views.py
+from netbox.views.generic import ObjectEditView
+from .models import Thing
+
+class ThingEditView(ObjectEditView):
+    queryset = Thing.objects.all()
+    template_name = 'myplugin/thing.html'
+    ...
+```
+
+## Object Views
+
+Below is the class definition for NetBox's BaseObjectView. The attributes and methods defined here are available on all generic views which handle a single object.
+
+::: netbox.views.generic.base.BaseObjectView
+    rendering:
+      show_source: false
+
+::: netbox.views.generic.ObjectView
+    selection:
+      members:
+        - get_object
+        - get_template_name
+    rendering:
+      show_source: false
+
+::: netbox.views.generic.ObjectEditView
+    selection:
+      members:
+        - get_object
+        - alter_object
+    rendering:
+      show_source: false
+
+::: netbox.views.generic.ObjectDeleteView
+    selection:
+      members:
+        - get_object
+    rendering:
+      show_source: false
+
+## Multi-Object Views
+
+Below is the class definition for NetBox's BaseMultiObjectView. The attributes and methods defined here are available on all generic views which deal with multiple objects.
+
+::: netbox.views.generic.base.BaseMultiObjectView
+    rendering:
+      show_source: false
+
+::: netbox.views.generic.ObjectListView
+    selection:
+      members:
+        - get_table
+        - export_table
+        - export_template
+    rendering:
+      show_source: false
+
+::: netbox.views.generic.BulkImportView
+    selection:
+      members: false
+    rendering:
+      show_source: false
+
+::: netbox.views.generic.BulkEditView
+    selection:
+      members: false
+    rendering:
+      show_source: false
+
+::: netbox.views.generic.BulkDeleteView
+    selection:
+      members:
+        - get_form
+    rendering:
+      show_source: false

+ 2 - 0
mkdocs.yml

@@ -28,6 +28,7 @@ plugins:
             - django.setup()
           rendering:
             heading_level: 3
+            members_order: source
             show_root_heading: true
             show_root_full_path: false
             show_root_toc_entry: false
@@ -102,6 +103,7 @@ nav:
         - Developing Plugins:
             - Introduction: 'plugins/development/index.md'
             - Model Features: 'plugins/development/model-features.md'
+            - Generic Views: 'plugins/development/generic-views.md'
         - Developing Plugins (Old): 'plugins/development.md'
     - Administration:
         - Authentication: 'administration/authentication.md'

+ 58 - 0
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 {}

+ 213 - 54
netbox/netbox/views/generic/bulk_views.py

@@ -3,21 +3,27 @@ import re
 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 @@ __all__ = (
     '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 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
         return new_objects
 
+    #
+    # Request handlers
+    #
+
     def get(self, request):
         # Set initial values for visible form fields from query args
         initial = {}
@@ -88,6 +248,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
     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 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
         return updated_objects
 
+    #
+    # Request handlers
+    #
+
     def get(self, request):
         return redirect(self.get_return_url(request))
 
@@ -419,17 +580,14 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         })
 
 
-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 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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}'

+ 86 - 246
netbox/netbox/views/generic/object_views.py

@@ -2,21 +2,16 @@ import logging
 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.htmx import is_htmx
 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 @@ __all__ = (
     '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 @@ class ObjectChildrenView(ObjectView):
         """
         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 @@ class ObjectChildrenView(ObjectView):
         })
 
 
-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 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
         return obj
 
+    #
+    # Request handlers
+    #
+
     def get(self, request):
         form = ImportForm()
 
@@ -445,17 +285,21 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         })
 
 
-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 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
     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 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 '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 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             '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 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 # 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):