diff --git a/netbox/core/signals.py b/netbox/core/signals.py index cd1b570e1..cfd10c988 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -232,6 +232,14 @@ def handle_deleted_object(sender, instance, **kwargs): # already been recorded, so nulling the FK and saving here would record an # UPDATE ObjectChange *after* the object's DELETE, corrupting the changelog and # breaking branch replay. (Ref: #22270) + # + # Note this is order-dependent: it only fires once the related object's own + # pre_delete has run (adding it to the set). If the cascade happens to delete + # this instance *before* the related object, the guard won't trigger and the + # related object still gets an UPDATE — but in the harmless UPDATE-then-DELETE + # order, not the corrupting DELETE-then-UPDATE order. Fully suppressing it in + # every ordering would require the complete deletion set, which isn't available + # from a pre_delete signal. if (related_object_type, obj.pk) in _signals_received.pre_delete: continue obj.snapshot() # Ensure the change record includes the "before" state diff --git a/netbox/core/tests/test_signals.py b/netbox/core/tests/test_signals.py index 958428d17..d691e72e2 100644 --- a/netbox/core/tests/test_signals.py +++ b/netbox/core/tests/test_signals.py @@ -240,11 +240,11 @@ class HandleDeletedObjectSignalTestCase(TestCase): changed_object_id=member_pk, ).order_by('time', 'pk').values_list('action', flat=True) ) - self.assertIn(ObjectChangeActionChoices.ACTION_DELETE, actions) - # The member's final change record must be its deletion, with no trailing update. - self.assertEqual(actions[-1], ObjectChangeActionChoices.ACTION_DELETE) - delete_index = actions.index(ObjectChangeActionChoices.ACTION_DELETE) - self.assertNotIn(ObjectChangeActionChoices.ACTION_UPDATE, actions[delete_index + 1:]) + # In this ordering the member's pre_delete fires before the LAG's, so the LAG + # skips it: the member's only change record for the request is its own deletion. + # Pre-fix, a spurious UPDATE was appended *after* this DELETE — assert the exact + # sequence so any trailing (or leading) update fails the test. + self.assertEqual(actions, [ObjectChangeActionChoices.ACTION_DELETE]) class ClearSignalHistorySignalTestCase(TestCase):