Explorar o código

Refactor generic views; add plugins dev documentation

jeremystretch %!s(int64=4) %!d(string=hai) anos
pai
achega
54834c47f8

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

@@ -0,0 +1,91 @@
+# 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 |
+
+### 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'
+    ...
+```
+
+## Generic Views Reference
+
+Below is the class definition for NetBox's base GenericView. The attributes and methods defined here are available on all generic views.
+
+::: netbox.views.generic.base.GenericView
+    rendering:
+      show_source: false
+
+!!! 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.
+
+::: netbox.views.generic.ObjectView
+    selection:
+      members:
+        - get_object
+        - get_template_name
+        - get_extra_context
+    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
+
+::: netbox.views.generic.ObjectListView
+    selection:
+      members:
+        - get_table
+        - get_extra_context
+        - export_yaml
+        - 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

+ 1 - 0
mkdocs.yml

@@ -102,6 +102,7 @@ nav:
         - Developing Plugins:
         - Developing Plugins:
             - Introduction: 'plugins/development/index.md'
             - Introduction: 'plugins/development/index.md'
             - Model Features: 'plugins/development/model-features.md'
             - Model Features: 'plugins/development/model-features.md'
+            - Generic Views: 'plugins/development/generic-views.md'
         - Developing Plugins (Old): 'plugins/development.md'
         - Developing Plugins (Old): 'plugins/development.md'
     - Administration:
     - Administration:
         - Authentication: 'administration/authentication.md'
         - Authentication: 'administration/authentication.md'

+ 15 - 0
netbox/netbox/views/generic/base.py

@@ -0,0 +1,15 @@
+from django.views.generic import View
+
+from utilities.views import ObjectPermissionRequiredMixin
+
+
+class GenericView(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

+ 188 - 37
netbox/netbox/views/generic/bulk_views.py

@@ -3,21 +3,27 @@ import re
 from copy import deepcopy
 from copy import deepcopy
 
 
 from django.contrib import messages
 from django.contrib import messages
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ValidationError
 from django.core.exceptions import FieldDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
 from django.db import transaction, IntegrityError
 from django.db.models import ManyToManyField, ProtectedError
 from django.db.models import ManyToManyField, ProtectedError
 from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 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 extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import PermissionsViolation
 from utilities.exceptions import PermissionsViolation
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields,
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields,
 )
 )
+from utilities.htmx import is_htmx
 from utilities.permissions import get_permission_for_model
 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 GenericView
 
 
 __all__ = (
 __all__ = (
     'BulkComponentCreateView',
     'BulkComponentCreateView',
@@ -26,24 +32,181 @@ __all__ = (
     'BulkEditView',
     'BulkEditView',
     'BulkImportView',
     'BulkImportView',
     'BulkRenameView',
     'BulkRenameView',
+    'ObjectListView',
 )
 )
 
 
 
 
-class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class ObjectListView(GenericView):
+    """
+    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
+        table: The django-tables2 Table used to render the objects list
+        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
+    table = 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
+
+    def get_extra_context(self, request):
+        """
+        Return any additional context data for the template.
+
+        Agrs:
+            request: The current request
+        """
+        return {}
+
+    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,
+        }
+        context.update(self.get_extra_context(request))
+
+        return render(request, self.template_name, context)
+
+    #
+    # 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)
+
+
+class BulkCreateView(GetReturnURLMixin, GenericView):
     """
     """
     Create new objects in bulk.
     Create new objects in bulk.
 
 
-    queryset: Base queryset for the objects being created
     form: Form class which provides the `pattern` field
     form: Form class which provides the `pattern` field
     model_form: The ModelForm used to create individual objects
     model_form: The ModelForm used to create individual objects
     pattern_target: Name of the field to be evaluated as a pattern (if any)
     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
     form = None
     model_form = None
     model_form = None
     pattern_target = ''
     pattern_target = ''
-    template_name = None
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'add')
         return get_permission_for_model(self.queryset.model, 'add')
@@ -135,20 +298,18 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         })
         })
 
 
 
 
-class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkImportView(GetReturnURLMixin, GenericView):
     """
     """
     Import objects in bulk (CSV format).
     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
+        table: The django-tables2 Table used to render the list of imported objects
+        widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
     """
     """
-    queryset = None
+    template_name = 'generic/object_bulk_import.html'
     model_form = None
     model_form = None
     table = None
     table = None
-    template_name = 'generic/object_bulk_import.html'
     widget_attrs = {}
     widget_attrs = {}
 
 
     def _import_form(self, *args, **kwargs):
     def _import_form(self, *args, **kwargs):
@@ -265,21 +426,19 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         })
         })
 
 
 
 
-class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkEditView(GetReturnURLMixin, GenericView):
     """
     """
     Edit objects in bulk.
     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
+        table: The table used to display devices being edited
+        form: The form class used to edit objects in bulk
     """
     """
-    queryset = None
+    template_name = 'generic/object_bulk_edit.html'
     filterset = None
     filterset = None
     table = None
     table = None
     form = None
     form = None
-    template_name = 'generic/object_bulk_edit.html'
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'change')
         return get_permission_for_model(self.queryset.model, 'change')
@@ -422,14 +581,10 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         })
         })
 
 
 
 
-class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkRenameView(GetReturnURLMixin, GenericView):
     """
     """
     An extendable view for renaming objects in bulk.
     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'
     template_name = 'generic/object_bulk_rename.html'
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -513,21 +668,18 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         })
         })
 
 
 
 
-class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkDeleteView(GetReturnURLMixin, GenericView):
     """
     """
     Delete objects in bulk.
     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
     filterset: FilterSet to apply when deleting by QuerySet
     table: The table used to display devices being deleted
     table: The table used to display devices being deleted
     form: The form class used to delete objects in bulk
     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
     filterset = None
     table = None
     table = None
     form = None
     form = None
-    template_name = 'generic/object_bulk_delete.html'
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'delete')
         return get_permission_for_model(self.queryset.model, 'delete')
@@ -613,18 +765,17 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 # Device/VirtualMachine components
 # Device/VirtualMachine components
 #
 #
 
 
-class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class BulkComponentCreateView(GetReturnURLMixin, GenericView):
     """
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
     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_model = None
     parent_field = None
     parent_field = None
     form = None
     form = None
-    queryset = None
     model_form = None
     model_form = None
     filterset = None
     filterset = None
     table = None
     table = None
-    template_name = 'generic/object_bulk_add_component.html'
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return f'dcim.add_{self.queryset.model._meta.model_name}'
         return f'dcim.add_{self.queryset.model._meta.model_name}'

+ 65 - 217
netbox/netbox/views/generic/object_views.py

@@ -2,21 +2,16 @@ import logging
 from copy import deepcopy
 from copy import deepcopy
 
 
 from django.contrib import messages
 from django.contrib import messages
-from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
 from django.db import transaction
 from django.db.models import ProtectedError
 from django.db.models import ProtectedError
 from django.forms.widgets import HiddenInput
 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 get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
 from django.utils.http import is_safe_url
 from django.utils.http import is_safe_url
 from django.utils.safestring import mark_safe
 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 extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortTransaction, PermissionsViolation
 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.permissions import get_permission_for_model
 from utilities.tables import configure_table
 from utilities.tables import configure_table
 from utilities.utils import normalize_querydict, prepare_cloned_fields
 from utilities.utils import normalize_querydict, prepare_cloned_fields
-from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
+from utilities.views import GetReturnURLMixin
+from .base import GenericView
 
 
 __all__ = (
 __all__ = (
     'ComponentCreateView',
     'ComponentCreateView',
@@ -33,27 +29,31 @@ __all__ = (
     'ObjectDeleteView',
     'ObjectDeleteView',
     'ObjectEditView',
     'ObjectEditView',
     'ObjectImportView',
     'ObjectImportView',
-    'ObjectListView',
     'ObjectView',
     'ObjectView',
 )
 )
 
 
 
 
-class ObjectView(ObjectPermissionRequiredMixin, View):
+class ObjectView(GenericView):
     """
     """
     Retrieve a single object for display.
     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):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'view')
         return get_permission_for_model(self.queryset.model, 'view')
 
 
+    def get_object(self, **kwargs):
+        """
+        Return the object being viewed, identified by the keyword arguments passed. If no matching object is found,
+        raise a 404 error.
+        """
+        return get_object_or_404(self.queryset, **kwargs)
+
     def get_template_name(self):
     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:
         if self.template_name is not None:
             return self.template_name
             return self.template_name
@@ -64,18 +64,20 @@ class ObjectView(ObjectPermissionRequiredMixin, View):
         """
         """
         Return any additional context data for the template.
         Return any additional context data for the template.
 
 
-        :param request: The current request
-        :param instance: The object being viewed
+        Args:
+            request: The current request
+            instance: The object being viewed
         """
         """
         return {}
         return {}
 
 
-    def get(self, request, *args, **kwargs):
+    def get(self, request, **kwargs):
         """
         """
-        GET request handler. *args and **kwargs are passed to identify the object being queried.
+        GET request handler. `*args` and `**kwargs` are passed to identify the object being queried.
 
 
-        :param request: The current request
+        Args:
+            request: The current request
         """
         """
-        instance = get_object_or_404(self.queryset, **kwargs)
+        instance = self.get_object(**kwargs)
 
 
         return render(request, self.get_template_name(), {
         return render(request, self.get_template_name(), {
             'object': instance,
             'object': instance,
@@ -87,15 +89,12 @@ class ObjectChildrenView(ObjectView):
     """
     """
     Display a table of child objects associated with the parent object.
     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
     child_model = None
     table = None
     table = None
     filterset = None
     filterset = None
-    template_name = None
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         """
         """
@@ -110,9 +109,10 @@ class ObjectChildrenView(ObjectView):
         """
         """
         Provides a hook for subclassed views to modify data before initializing the table.
         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
         return queryset
 
 
@@ -120,7 +120,7 @@ class ObjectChildrenView(ObjectView):
         """
         """
         GET handler for rendering child objects.
         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)
         child_objects = self.get_children(request, instance)
 
 
         if self.filterset:
         if self.filterset:
@@ -152,171 +152,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, GenericView):
     """
     """
     Import a single object (YAML or JSON format).
     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
     model_form = None
     related_object_forms = dict()
     related_object_forms = dict()
-    template_name = 'generic/object_import.html'
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'add')
         return get_permission_for_model(self.queryset.model, 'add')
@@ -445,17 +291,21 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         })
         })
 
 
 
 
-class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class ObjectEditView(GetReturnURLMixin, GenericView):
     """
     """
     Create or edit a single object.
     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'
     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):
     def get_required_permission(self):
         # self._permission_action is set by dispatch() to either "add" or "change" depending on whether
         # self._permission_action is set by dispatch() to either "add" or "change" depending on whether
@@ -466,13 +316,16 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         """
         """
         Return an instance for editing. If a PK has been specified, this will be an existing object.
         Return an instance for editing. If a PK has been specified, this will be an existing object.
 
 
-        :param kwargs: URL path kwargs
+        Args:
+            kwargs: URL path kwargs
         """
         """
         if 'pk' in kwargs:
         if 'pk' in kwargs:
             obj = get_object_or_404(self.queryset, **kwargs)
             obj = get_object_or_404(self.queryset, **kwargs)
+
             # Take a snapshot of change-logged models
             # Take a snapshot of change-logged models
             if hasattr(obj, 'snapshot'):
             if hasattr(obj, 'snapshot'):
                 obj.snapshot()
                 obj.snapshot()
+
             return obj
             return obj
 
 
         return self.queryset.model()
         return self.queryset.model()
@@ -482,24 +335,20 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         Provides a hook for views to modify an object before it is processed. For example, a parent object can be
         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.
         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
         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):
     def get(self, request, *args, **kwargs):
         """
         """
         GET request handler.
         GET request handler.
 
 
-        :param request: The current request
+        Args:
+            request: The current request
         """
         """
         obj = self.get_object(**kwargs)
         obj = self.get_object(**kwargs)
         obj = self.alter_object(obj, request, args, kwargs)
         obj = self.alter_object(obj, request, args, kwargs)
@@ -519,7 +368,8 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         """
         """
         POST request handler.
         POST request handler.
 
 
-        :param request: The current request
+        Args:
+            request: The current request
         """
         """
         logger = logging.getLogger('netbox.views.ObjectEditView')
         logger = logging.getLogger('netbox.views.ObjectEditView')
         obj = self.get_object(**kwargs)
         obj = self.get_object(**kwargs)
@@ -588,14 +438,10 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         })
         })
 
 
 
 
-class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class ObjectDeleteView(GetReturnURLMixin, GenericView):
     """
     """
     Delete a single object.
     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'
     template_name = 'generic/object_delete.html'
 
 
     def get_required_permission(self):
     def get_required_permission(self):
@@ -605,7 +451,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         """
         """
         Return an instance for deletion. If a PK has been specified, this will be an existing object.
         Return an instance for deletion. If a PK has been specified, this will be an existing object.
 
 
-        :param kwargs: URL path kwargs
+        Args:
+            kwargs: URL path kwargs
         """
         """
         obj = get_object_or_404(self.queryset, **kwargs)
         obj = get_object_or_404(self.queryset, **kwargs)
 
 
@@ -619,7 +466,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         """
         """
         GET request handler.
         GET request handler.
 
 
-        :param request: The current request
+        Args:
+            request: The current request
         """
         """
         obj = self.get_object(**kwargs)
         obj = self.get_object(**kwargs)
         form = ConfirmationForm(initial=request.GET)
         form = ConfirmationForm(initial=request.GET)
@@ -646,7 +494,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         """
         """
         POST request handler.
         POST request handler.
 
 
-        :param request: The current request
+        Args:
+            request: The current request
         """
         """
         logger = logging.getLogger('netbox.views.ObjectDeleteView')
         logger = logging.getLogger('netbox.views.ObjectDeleteView')
         obj = self.get_object(**kwargs)
         obj = self.get_object(**kwargs)
@@ -687,14 +536,13 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 # Device/VirtualMachine components
 # Device/VirtualMachine components
 #
 #
 
 
-class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+class ComponentCreateView(GetReturnURLMixin, GenericView):
     """
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     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
     form = None
     model_form = None
     model_form = None
-    template_name = 'dcim/component_create.html'
     patterned_fields = ('name', 'label')
     patterned_fields = ('name', 'label')
 
 
     def get_required_permission(self):
     def get_required_permission(self):