|
|
@@ -1,8 +1,8 @@
|
|
|
-import importlib
|
|
|
import logging
|
|
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
|
from django.core.exceptions import ValidationError
|
|
|
+from django.db.models.fields.reverse_related import ManyToManyRel
|
|
|
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
|
|
from django.dispatch import receiver, Signal
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
@@ -12,9 +12,10 @@ from core.signals import job_end, job_start
|
|
|
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
|
|
from extras.events import process_event_rules
|
|
|
from extras.models import EventRule
|
|
|
-from extras.validators import CustomValidator
|
|
|
+from extras.validators import run_validators
|
|
|
from netbox.config import get_config
|
|
|
from netbox.context import current_request, events_queue
|
|
|
+from netbox.models.features import ChangeLoggingMixin
|
|
|
from netbox.signals import post_clean
|
|
|
from utilities.exceptions import AbortRequest
|
|
|
from .choices import ObjectChangeActionChoices
|
|
|
@@ -68,7 +69,7 @@ def handle_changed_object(sender, instance, **kwargs):
|
|
|
else:
|
|
|
return
|
|
|
|
|
|
- # Create/update an ObejctChange record for this change
|
|
|
+ # Create/update an ObjectChange record for this change
|
|
|
objectchange = instance.to_objectchange(action)
|
|
|
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
|
|
|
# for this object by this request and update it
|
|
|
@@ -108,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
|
|
|
"""
|
|
|
Fires when an object is deleted.
|
|
|
"""
|
|
|
+ # Run any deletion protection rules for the object. Note that this must occur prior
|
|
|
+ # to queueing any events for the object being deleted, in case a validation error is
|
|
|
+ # raised, causing the deletion to fail.
|
|
|
+ model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
|
|
+ validators = get_config().PROTECTION_RULES.get(model_name, [])
|
|
|
+ try:
|
|
|
+ run_validators(instance, validators)
|
|
|
+ except ValidationError as e:
|
|
|
+ raise AbortRequest(
|
|
|
+ _("Deletion is prevented by a protection rule: {message}").format(message=e)
|
|
|
+ )
|
|
|
+
|
|
|
# Get the current request, or bail if not set
|
|
|
request = current_request.get()
|
|
|
if request is None:
|
|
|
@@ -122,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
|
|
|
objectchange.request_id = request.id
|
|
|
objectchange.save()
|
|
|
|
|
|
+ # Django does not automatically send an m2m_changed signal for the reverse direction of a
|
|
|
+ # many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
|
|
|
+ # trigger one manually. We do this by checking for any reverse M2M relationships on the
|
|
|
+ # instance being deleted, and explicitly call .remove() on the remote M2M field to delete
|
|
|
+ # the association. This triggers an m2m_changed signal with the `post_remove` action type
|
|
|
+ # for the forward direction of the relationship, ensuring that the change is recorded.
|
|
|
+ for relation in instance._meta.related_objects:
|
|
|
+ if type(relation) is not ManyToManyRel:
|
|
|
+ continue
|
|
|
+ related_model = relation.related_model
|
|
|
+ related_field_name = relation.remote_field.name
|
|
|
+ if not issubclass(related_model, ChangeLoggingMixin):
|
|
|
+ # We only care about triggering the m2m_changed signal for models which support
|
|
|
+ # change logging
|
|
|
+ continue
|
|
|
+ for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
|
|
|
+ obj.snapshot() # Ensure the change record includes the "before" state
|
|
|
+ getattr(obj, related_field_name).remove(instance)
|
|
|
+
|
|
|
# Enqueue webhooks
|
|
|
queue = events_queue.get()
|
|
|
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
|
|
@@ -186,45 +218,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
|
|
|
# Custom validation
|
|
|
#
|
|
|
|
|
|
-def run_validators(instance, validators):
|
|
|
-
|
|
|
- for validator in validators:
|
|
|
-
|
|
|
- # Loading a validator class by dotted path
|
|
|
- if type(validator) is str:
|
|
|
- module, cls = validator.rsplit('.', 1)
|
|
|
- validator = getattr(importlib.import_module(module), cls)()
|
|
|
-
|
|
|
- # Constructing a new instance on the fly from a ruleset
|
|
|
- elif type(validator) is dict:
|
|
|
- validator = CustomValidator(validator)
|
|
|
-
|
|
|
- validator(instance)
|
|
|
-
|
|
|
-
|
|
|
@receiver(post_clean)
|
|
|
def run_save_validators(sender, instance, **kwargs):
|
|
|
+ """
|
|
|
+ Run any custom validation rules for the model prior to calling save().
|
|
|
+ """
|
|
|
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
|
|
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
|
|
|
|
|
|
run_validators(instance, validators)
|
|
|
|
|
|
|
|
|
-@receiver(pre_delete)
|
|
|
-def run_delete_validators(sender, instance, **kwargs):
|
|
|
- model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
|
|
- validators = get_config().PROTECTION_RULES.get(model_name, [])
|
|
|
-
|
|
|
- try:
|
|
|
- run_validators(instance, validators)
|
|
|
- except ValidationError as e:
|
|
|
- raise AbortRequest(
|
|
|
- _("Deletion is prevented by a protection rule: {message}").format(
|
|
|
- message=e
|
|
|
- )
|
|
|
- )
|
|
|
-
|
|
|
-
|
|
|
#
|
|
|
# Tags
|
|
|
#
|