diff --git a/docs/plugins/development/generic-views.md b/docs/plugins/development/generic-views.md new file mode 100644 index 000000000..1a444ca2c --- /dev/null +++ b/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 diff --git a/mkdocs.yml b/mkdocs.yml index 585e6d76f..dbd31cb50 100644 --- a/mkdocs.yml +++ b/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' diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py new file mode 100644 index 000000000..3ad3bcf67 --- /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 index e9b213a95..82e1dc217 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/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 - filterset = None - table = None - form = None template_name = 'generic/object_bulk_edit.html' + filterset = None + form = None 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}' diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index b6df5e3c2..09a102442 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/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):