mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
Closes #9075: Introduce AbortRequest exception for cleanly interrupting object mutations
This commit is contained in:
parent
65f4895dd6
commit
3a6f46bf38
28
docs/plugins/development/exceptions.md
Normal file
28
docs/plugins/development/exceptions.md
Normal 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.
|
@ -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
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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:
|
||||||
|
@ -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, {
|
||||||
|
@ -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):
|
||||||
|
Loading…
Reference in New Issue
Block a user