diff --git a/docs/plugins/development/exceptions.md b/docs/plugins/development/exceptions.md new file mode 100644 index 000000000..80f5db258 --- /dev/null +++ b/docs/plugins/development/exceptions.md @@ -0,0 +1,28 @@ +# Exceptions + +The exception classes listed here may be raised by a plugin to alter NetBox's default behavior in various scenarios. + +## `AbortRequest` + +NetBox provides several [generic views](./views.md) and [REST API viewsets](./rest-api.md) which facilitate the creation, modification, and deletion of objects, either individually or in bulk. Under certain conditions, it may be desirable for a plugin to interrupt these actions and cleanly abort the request, reporting an error message to the end user or API consumer. + +For example, a plugin may prohibit the creation of a site with a prohibited name by connecting a receiver to Django's `pre_save` signal for the Site model: + +```python +from django.db.models.signals import pre_save +from django.dispatch import receiver +from dcim.models import Site +from utilities.exceptions import AbortRequest + +PROHIBITED_NAMES = ('foo', 'bar', 'baz') + +@receiver(pre_save, sender=Site) +def test_abort_request(instance, **kwargs): + if instance.name.lower() in PROHIBITED_NAMES: + raise AbortRequest(f"Site name can't be {instance.name}!") +``` + +An error message must be supplied when raising `AbortRequest`. This will be conveyed to the user and should clearly explain the reason for which the request was aborted, as well as any potential remedy. + +!!! tip "Consider custom validation rules" + This exception is intended to be used for handling complex evaluation logic and should be used sparingly. For simple object validation (such as the contrived example above), consider using [custom validation rules](../../customization/custom-validation.md) instead. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 62644f55f..a0abb81c4 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -35,6 +35,7 @@ ### Plugins API +* [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations * [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes diff --git a/mkdocs.yml b/mkdocs.yml index 507b25627..88a2794e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -118,6 +118,7 @@ nav: - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' + - Exceptions: 'plugins/development/exceptions.md' - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 462c07c6f..2d3780bde 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet from extras.models import ExportTemplate from netbox.api.exceptions import SerializerNotFound from utilities.api import get_serializer_for_model +from utilities.exceptions import AbortRequest from .mixins import * __all__ = ( @@ -125,6 +126,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali *args, **kwargs ) + except AbortRequest as e: + logger.debug(e.message) + return self.finalize_response( + request, + Response({'detail': e.message}, status=400), + *args, + **kwargs + ) def list(self, request, *args, **kwargs): """ diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index bb1c2b8e3..82244bcd2 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -1,6 +1,5 @@ import logging import re -from collections import defaultdict from copy import deepcopy from django.contrib import messages @@ -12,11 +11,12 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django_tables2.export import TableExport +from django.utils.safestring import mark_safe 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.exceptions import AbortRequest, PermissionsViolation from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, ) @@ -264,10 +264,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): except IntegrityError: pass - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) + clear_webhooks.send(sender=self) else: logger.debug("Form validation failed") @@ -392,10 +392,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): except ValidationError: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object import failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -542,10 +541,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): messages.error(self.request, ", ".join(e.messages)) clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -639,10 +637,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): messages.success(request, f"Renamed {len(selected_objects)} {model_name}") return redirect(self.get_return_url(request)) - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -717,11 +714,17 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): if hasattr(obj, 'snapshot'): obj.snapshot() obj.delete() + except ProtectedError as e: logger.info("Caught ProtectedError while attempting to delete objects") handle_protectederror(queryset, request, e) return redirect(self.get_return_url(request)) + except AbortRequest as e: + logger.debug(e.message) + messages.error(request, mark_safe(e.message)) + return redirect(self.get_return_url(request)) + msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}" logger.info(msg) messages.success(request, msg) @@ -829,10 +832,9 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): except IntegrityError: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Component creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) if not form.errors: diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 82867b429..dc078a7e2 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -13,7 +13,7 @@ from django.utils.safestring import mark_safe from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortTransaction, PermissionsViolation +from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model @@ -246,10 +246,9 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView): except AbortTransaction: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) if not model_form.errors: @@ -410,10 +409,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): return redirect(return_url) - except PermissionsViolation: - msg = "Object save failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -489,11 +487,17 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): try: obj.delete() + except ProtectedError as e: logger.info("Caught ProtectedError while attempting to delete object") handle_protectederror([obj], request, e) return redirect(obj.get_absolute_url()) + except AbortRequest as e: + logger.debug(e.message) + messages.error(request, mark_safe(e.message)) + return redirect(obj.get_absolute_url()) + msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj) logger.info(msg) messages.success(request, msg) @@ -603,10 +607,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): else: return redirect(self.get_return_url(request)) - except PermissionsViolation: - msg = "Component creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) return render(request, self.template_name, { diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py index 4ba62bc01..657e90745 100644 --- a/netbox/utilities/exceptions.py +++ b/netbox/utilities/exceptions.py @@ -1,6 +1,13 @@ from rest_framework import status from rest_framework.exceptions import APIException +__all__ = ( + 'AbortRequest', + 'AbortTransaction', + 'PermissionsViolation', + 'RQWorkerNotRunningException', +) + class AbortTransaction(Exception): """ @@ -9,12 +16,20 @@ class AbortTransaction(Exception): pass +class AbortRequest(Exception): + """ + Raised to cleanly abort a request (for example, by a pre_save signal receiver). + """ + def __init__(self, message): + self.message = message + + class PermissionsViolation(Exception): """ Raised when an operation was prevented because it would violate the allowed permissions. """ - pass + message = "Operation failed due to object-level permissions violation" class RQWorkerNotRunningException(APIException):