Closes #9075: Introduce AbortRequest exception for cleanly interrupting object mutations

This commit is contained in:
jeremystretch 2022-06-30 15:15:07 -04:00
parent 65f4895dd6
commit 3a6f46bf38
7 changed files with 95 additions and 36 deletions

View File

@ -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.

View File

@ -35,6 +35,7 @@
### Plugins API ### 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 * [#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 * [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes

View File

@ -118,6 +118,7 @@ nav:
- REST API: 'plugins/development/rest-api.md' - REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql-api.md' - GraphQL API: 'plugins/development/graphql-api.md'
- Background Tasks: 'plugins/development/background-tasks.md' - Background Tasks: 'plugins/development/background-tasks.md'
- Exceptions: 'plugins/development/exceptions.md'
- Administration: - Administration:
- Authentication: - Authentication:
- Overview: 'administration/authentication/overview.md' - Overview: 'administration/authentication/overview.md'

View File

@ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet
from extras.models import ExportTemplate from extras.models import ExportTemplate
from netbox.api.exceptions import SerializerNotFound from netbox.api.exceptions import SerializerNotFound
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.exceptions import AbortRequest
from .mixins import * from .mixins import *
__all__ = ( __all__ = (
@ -125,6 +126,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
*args, *args,
**kwargs **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): def list(self, request, *args, **kwargs):
""" """

View File

@ -1,6 +1,5 @@
import logging import logging
import re import re
from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from django.contrib import messages from django.contrib import messages
@ -12,11 +11,12 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
from django.http import HttpResponse 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_tables2.export import TableExport from django_tables2.export import TableExport
from django.utils.safestring import mark_safe
from extras.models import ExportTemplate 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 AbortRequest, 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,
) )
@ -264,10 +264,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
except IntegrityError: except IntegrityError:
pass pass
except PermissionsViolation: except (AbortRequest, PermissionsViolation) as e:
msg = "Object creation failed due to object-level permissions violation" logger.debug(e.message)
logger.debug(msg) form.add_error(None, e.message)
form.add_error(None, msg) clear_webhooks.send(sender=self)
else: else:
logger.debug("Form validation failed") logger.debug("Form validation failed")
@ -392,10 +392,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
except ValidationError: except ValidationError:
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
except PermissionsViolation: except (AbortRequest, PermissionsViolation) as e:
msg = "Object import failed due to object-level permissions violation" logger.debug(e.message)
logger.debug(msg) form.add_error(None, e.message)
form.add_error(None, msg)
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
else: else:
@ -542,10 +541,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
messages.error(self.request, ", ".join(e.messages)) messages.error(self.request, ", ".join(e.messages))
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
except PermissionsViolation: except (AbortRequest, PermissionsViolation) as e:
msg = "Object update failed due to object-level permissions violation" logger.debug(e.message)
logger.debug(msg) form.add_error(None, e.message)
form.add_error(None, msg)
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
else: else:
@ -639,10 +637,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
messages.success(request, f"Renamed {len(selected_objects)} {model_name}") messages.success(request, f"Renamed {len(selected_objects)} {model_name}")
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))
except PermissionsViolation: except (AbortRequest, PermissionsViolation) as e:
msg = "Object update failed due to object-level permissions violation" logger.debug(e.message)
logger.debug(msg) form.add_error(None, e.message)
form.add_error(None, msg)
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
else: else:
@ -717,11 +714,17 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
if hasattr(obj, 'snapshot'): if hasattr(obj, 'snapshot'):
obj.snapshot() obj.snapshot()
obj.delete() obj.delete()
except ProtectedError as e: except ProtectedError as e:
logger.info("Caught ProtectedError while attempting to delete objects") logger.info("Caught ProtectedError while attempting to delete objects")
handle_protectederror(queryset, request, e) handle_protectederror(queryset, request, e)
return redirect(self.get_return_url(request)) 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}" msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
logger.info(msg) logger.info(msg)
messages.success(request, msg) messages.success(request, msg)
@ -829,10 +832,9 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
except IntegrityError: except IntegrityError:
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
except PermissionsViolation: except (AbortRequest, PermissionsViolation) as e:
msg = "Component creation failed due to object-level permissions violation" logger.debug(e.message)
logger.debug(msg) form.add_error(None, e.message)
form.add_error(None, msg)
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
if not form.errors: if not form.errors:

View File

@ -13,7 +13,7 @@ from django.utils.safestring import mark_safe
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 AbortRequest, AbortTransaction, PermissionsViolation
from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
@ -246,10 +246,9 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView):
except AbortTransaction: except AbortTransaction:
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
except PermissionsViolation: except (AbortRequest, PermissionsViolation) as e:
msg = "Object creation failed due to object-level permissions violation" logger.debug(e.message)
logger.debug(msg) form.add_error(None, e.message)
form.add_error(None, msg)
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
if not model_form.errors: if not model_form.errors:
@ -410,10 +409,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
return redirect(return_url) return redirect(return_url)
except PermissionsViolation: except (AbortRequest, PermissionsViolation) as e:
msg = "Object save failed due to object-level permissions violation" logger.debug(e.message)
logger.debug(msg) form.add_error(None, e.message)
form.add_error(None, msg)
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
else: else:
@ -489,11 +487,17 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
try: try:
obj.delete() obj.delete()
except ProtectedError as e: except ProtectedError as e:
logger.info("Caught ProtectedError while attempting to delete object") logger.info("Caught ProtectedError while attempting to delete object")
handle_protectederror([obj], request, e) handle_protectederror([obj], request, e)
return redirect(obj.get_absolute_url()) 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) msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
logger.info(msg) logger.info(msg)
messages.success(request, msg) messages.success(request, msg)
@ -603,10 +607,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
else: else:
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))
except PermissionsViolation: except (AbortRequest, PermissionsViolation) as e:
msg = "Component creation failed due to object-level permissions violation" logger.debug(e.message)
logger.debug(msg) form.add_error(None, e.message)
form.add_error(None, msg)
clear_webhooks.send(sender=self) clear_webhooks.send(sender=self)
return render(request, self.template_name, { return render(request, self.template_name, {

View File

@ -1,6 +1,13 @@
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
__all__ = (
'AbortRequest',
'AbortTransaction',
'PermissionsViolation',
'RQWorkerNotRunningException',
)
class AbortTransaction(Exception): class AbortTransaction(Exception):
""" """
@ -9,12 +16,20 @@ class AbortTransaction(Exception):
pass 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): class PermissionsViolation(Exception):
""" """
Raised when an operation was prevented because it would violate the Raised when an operation was prevented because it would violate the
allowed permissions. allowed permissions.
""" """
pass message = "Operation failed due to object-level permissions violation"
class RQWorkerNotRunningException(APIException): class RQWorkerNotRunningException(APIException):