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
* [#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

View File

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

View File

@ -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):
"""

View File

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

View File

@ -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, {

View File

@ -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):