From 605c61ef5bcc820ac6aa216aade40e7cd07162d7 Mon Sep 17 00:00:00 2001 From: Vincent Simonin Date: Mon, 8 Dec 2025 17:06:13 +0100 Subject: [PATCH 1/2] Fix on delete cascade entity order Since [#20708](https://github.com/netbox-community/netbox/pull/20708) relation with a on delete RESTRICT are not deleted in the proper order. Then the error `violate not-null constraint` occurs and breaks the delete cascade feature. --- netbox/core/signals.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 2994aaa41f6..f7fe1815a32 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError -from django.db.models import CASCADE +from django.db.models import CASCADE, RESTRICT from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete from django.dispatch import receiver, Signal @@ -47,6 +47,7 @@ # Object types # + @receiver(post_migrate) def update_object_types(sender, **kwargs): """ @@ -133,7 +134,7 @@ def handle_changed_object(sender, instance, **kwargs): prev_change := ObjectChange.objects.filter( changed_object_type=ContentType.objects.get_for_model(instance), changed_object_id=instance.pk, - request_id=request.id + request_id=request.id, ).first() ): prev_change.postchange_data = objectchange.postchange_data @@ -172,9 +173,7 @@ def handle_deleted_object(sender, instance, **kwargs): try: run_validators(instance, validators) except ValidationError as e: - raise AbortRequest( - _("Deletion is prevented by a protection rule: {message}").format(message=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() @@ -221,7 +220,12 @@ def handle_deleted_object(sender, instance, **kwargs): obj.snapshot() # Ensure the change record includes the "before" state if type(relation) is ManyToManyRel: getattr(obj, related_field_name).remove(instance) - elif type(relation) is ManyToOneRel and relation.null and relation.on_delete is not CASCADE: + elif ( + type(relation) is ManyToOneRel + and relation.null + and relation.on_delete is not CASCADE + and relation.on_delete is not RESTRICT + ): setattr(obj, related_field_name, None) obj.save() @@ -256,6 +260,7 @@ def clear_events_queue(sender, **kwargs): # DataSource handlers # + @receiver(post_save, sender=DataSource) def enqueue_sync_job(instance, created, **kwargs): """ @@ -267,9 +272,10 @@ def enqueue_sync_job(instance, created, **kwargs): SyncDataSourceJob.enqueue_once(instance, interval=instance.sync_interval) elif not created: # Delete any previously scheduled recurring jobs for this DataSource - for job in SyncDataSourceJob.get_jobs(instance).defer('data').filter( - interval__isnull=False, - status=JobStatusChoices.STATUS_SCHEDULED + for job in ( + SyncDataSourceJob.get_jobs(instance) + .defer('data') + .filter(interval__isnull=False, status=JobStatusChoices.STATUS_SCHEDULED) ): # Call delete() per instance to ensure the associated background task is deleted as well job.delete() From 6b0a0fd1071ae7826c79138af82ae543ab5109d2 Mon Sep 17 00:00:00 2001 From: Vincent Simonin Date: Mon, 22 Dec 2025 17:32:39 +0100 Subject: [PATCH 2/2] Revert unrelated and simplify changes --- netbox/core/signals.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/netbox/core/signals.py b/netbox/core/signals.py index f7fe1815a32..d918d2389f2 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -47,7 +47,6 @@ # Object types # - @receiver(post_migrate) def update_object_types(sender, **kwargs): """ @@ -134,7 +133,7 @@ def handle_changed_object(sender, instance, **kwargs): prev_change := ObjectChange.objects.filter( changed_object_type=ContentType.objects.get_for_model(instance), changed_object_id=instance.pk, - request_id=request.id, + request_id=request.id ).first() ): prev_change.postchange_data = objectchange.postchange_data @@ -173,7 +172,9 @@ def handle_deleted_object(sender, instance, **kwargs): try: run_validators(instance, validators) except ValidationError as e: - raise AbortRequest(_("Deletion is prevented by a protection rule: {message}").format(message=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() @@ -220,12 +221,7 @@ def handle_deleted_object(sender, instance, **kwargs): obj.snapshot() # Ensure the change record includes the "before" state if type(relation) is ManyToManyRel: getattr(obj, related_field_name).remove(instance) - elif ( - type(relation) is ManyToOneRel - and relation.null - and relation.on_delete is not CASCADE - and relation.on_delete is not RESTRICT - ): + elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT): setattr(obj, related_field_name, None) obj.save() @@ -260,7 +256,6 @@ def clear_events_queue(sender, **kwargs): # DataSource handlers # - @receiver(post_save, sender=DataSource) def enqueue_sync_job(instance, created, **kwargs): """ @@ -272,10 +267,9 @@ def enqueue_sync_job(instance, created, **kwargs): SyncDataSourceJob.enqueue_once(instance, interval=instance.sync_interval) elif not created: # Delete any previously scheduled recurring jobs for this DataSource - for job in ( - SyncDataSourceJob.get_jobs(instance) - .defer('data') - .filter(interval__isnull=False, status=JobStatusChoices.STATUS_SCHEDULED) + for job in SyncDataSourceJob.get_jobs(instance).defer('data').filter( + interval__isnull=False, + status=JobStatusChoices.STATUS_SCHEDULED ): # Call delete() per instance to ensure the associated background task is deleted as well job.delete()