Jelajahi Sumber

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

jeremystretch 3 tahun lalu
induk
melakukan
3a6f46bf38

+ 28 - 0
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.

+ 1 - 0
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
 

+ 1 - 0
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'

+ 9 - 0
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):
         """

+ 24 - 22
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:

+ 16 - 13
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, {

+ 16 - 1
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):