diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py index 42d0d96ea6..4e3d025a56 100644 --- a/django/contrib/admin/utils.py +++ b/django/contrib/admin/utils.py @@ -116,7 +116,7 @@ def get_deleted_objects(objs, request, admin_site): return [], {}, set(), [] else: using = router.db_for_write(obj._meta.model) - collector = NestedObjects(using=using) + collector = NestedObjects(using=using, origin=objs) collector.collect(objs) perms_needed = set() diff --git a/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py b/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py index e2856bb0f7..5593ecb469 100644 --- a/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py +++ b/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py @@ -51,7 +51,7 @@ class Command(BaseCommand): ct_info = [] for ct in to_remove: ct_info.append(' - Content type for %s.%s' % (ct.app_label, ct.model)) - collector = NoFastDeleteCollector(using=using) + collector = NoFastDeleteCollector(using=using, origin=ct) collector.collect([ct]) for obj_type, objs in collector.data.items(): diff --git a/django/db/models/base.py b/django/db/models/base.py index 0d50dad0c9..793cd936f1 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -987,7 +987,7 @@ class Model(metaclass=ModelBase): "to None." % (self._meta.object_name, self._meta.pk.attname) ) using = using or router.db_for_write(self.__class__, instance=self) - collector = Collector(using=using) + collector = Collector(using=using, origin=self) collector.collect([self], keep_parents=keep_parents) return collector.delete() diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index d8d8b25990..b99337a309 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -76,8 +76,10 @@ def get_candidate_relations_to_delete(opts): class Collector: - def __init__(self, using): + def __init__(self, using, origin=None): self.using = using + # A Model or QuerySet object. + self.origin = origin # Initially, {model: {instances}}, later values become lists. self.data = defaultdict(set) # {model: {(field, value): {instances}}} @@ -404,7 +406,8 @@ class Collector: for model, obj in self.instances_with_model(): if not model._meta.auto_created: signals.pre_delete.send( - sender=model, instance=obj, using=self.using + sender=model, instance=obj, using=self.using, + origin=self.origin, ) # fast deletes @@ -435,7 +438,8 @@ class Collector: if not model._meta.auto_created: for obj in instances: signals.post_delete.send( - sender=model, instance=obj, using=self.using + sender=model, instance=obj, using=self.using, + origin=self.origin, ) # update collected instances diff --git a/django/db/models/query.py b/django/db/models/query.py index fb6639793a..86b1631f67 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -753,7 +753,7 @@ class QuerySet: del_query.query.select_related = False del_query.query.clear_ordering(force=True) - collector = Collector(using=del_query.db) + collector = Collector(using=del_query.db, origin=self) collector.collect(del_query) deleted, _rows_count = collector.delete() diff --git a/docs/ref/signals.txt b/docs/ref/signals.txt index 4eb55c906a..6f0ce0527c 100644 --- a/docs/ref/signals.txt +++ b/docs/ref/signals.txt @@ -195,6 +195,12 @@ Arguments sent with this signal: ``using`` The database alias being used. +``origin`` + .. versionadded:: 4.1 + + The origin of the deletion being the instance of a ``Model`` or + ``QuerySet`` class. + ``post_delete`` --------------- @@ -219,6 +225,12 @@ Arguments sent with this signal: ``using`` The database alias being used. +``origin`` + .. versionadded:: 4.1 + + The origin of the deletion being the instance of a ``Model`` or + ``QuerySet`` class. + ``m2m_changed`` --------------- diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt index 8cdec777f9..fa1c05bc80 100644 --- a/docs/releases/4.1.txt +++ b/docs/releases/4.1.txt @@ -249,7 +249,9 @@ Serialization Signals ~~~~~~~ -* ... +* The :data:`~django.db.models.signals.pre_delete` and + :data:`~django.db.models.signals.post_delete` signals now dispatch the + ``origin`` of the deletion. Templates ~~~~~~~~~ diff --git a/tests/signals/models.py b/tests/signals/models.py index ca30cdc51f..b758244749 100644 --- a/tests/signals/models.py +++ b/tests/signals/models.py @@ -30,3 +30,8 @@ class Book(models.Model): def __str__(self): return self.name + + +class Page(models.Model): + book = models.ForeignKey(Book, on_delete=models.CASCADE) + text = models.TextField() diff --git a/tests/signals/tests.py b/tests/signals/tests.py index c4c692ea3e..b89b7bd91c 100644 --- a/tests/signals/tests.py +++ b/tests/signals/tests.py @@ -7,7 +7,7 @@ from django.dispatch import receiver from django.test import SimpleTestCase, TestCase from django.test.utils import isolate_apps -from .models import Author, Book, Car, Person +from .models import Author, Book, Car, Page, Person class BaseSignalSetup: @@ -118,9 +118,9 @@ class SignalTests(BaseSignalSetup, TestCase): def test_delete_signals(self): data = [] - def pre_delete_handler(signal, sender, instance, **kwargs): + def pre_delete_handler(signal, sender, instance, origin, **kwargs): data.append( - (instance, sender, instance.id is None) + (instance, sender, instance.id is None, origin) ) # #8285: signals can be any callable @@ -128,9 +128,9 @@ class SignalTests(BaseSignalSetup, TestCase): def __init__(self, data): self.data = data - def __call__(self, signal, sender, instance, **kwargs): + def __call__(self, signal, sender, instance, origin, **kwargs): self.data.append( - (instance, sender, instance.id is None) + (instance, sender, instance.id is None, origin) ) post_delete_handler = PostDeleteHandler(data) @@ -140,8 +140,8 @@ class SignalTests(BaseSignalSetup, TestCase): p1 = Person.objects.create(first_name="John", last_name="Smith") p1.delete() self.assertEqual(data, [ - (p1, Person, False), - (p1, Person, False), + (p1, Person, False, p1), + (p1, Person, False, p1), ]) data[:] = [] @@ -152,8 +152,8 @@ class SignalTests(BaseSignalSetup, TestCase): p2.save() p2.delete() self.assertEqual(data, [ - (p2, Person, False), - (p2, Person, False), + (p2, Person, False, p2), + (p2, Person, False, p2), ]) data[:] = [] @@ -167,6 +167,78 @@ class SignalTests(BaseSignalSetup, TestCase): signals.pre_delete.disconnect(pre_delete_handler) signals.post_delete.disconnect(post_delete_handler) + def test_delete_signals_origin_model(self): + data = [] + + def pre_delete_handler(signal, sender, instance, origin, **kwargs): + data.append((sender, origin)) + + def post_delete_handler(signal, sender, instance, origin, **kwargs): + data.append((sender, origin)) + + person = Person.objects.create(first_name='John', last_name='Smith') + book = Book.objects.create(name='Rayuela') + Page.objects.create(text='Page 1', book=book) + Page.objects.create(text='Page 2', book=book) + + signals.pre_delete.connect(pre_delete_handler, weak=False) + signals.post_delete.connect(post_delete_handler, weak=False) + try: + # Instance deletion. + person.delete() + self.assertEqual(data, [(Person, person), (Person, person)]) + data[:] = [] + # Cascade deletion. + book.delete() + self.assertEqual(data, [ + (Page, book), + (Page, book), + (Book, book), + (Page, book), + (Page, book), + (Book, book), + ]) + finally: + signals.pre_delete.disconnect(pre_delete_handler) + signals.post_delete.disconnect(post_delete_handler) + + def test_delete_signals_origin_queryset(self): + data = [] + + def pre_delete_handler(signal, sender, instance, origin, **kwargs): + data.append((sender, origin)) + + def post_delete_handler(signal, sender, instance, origin, **kwargs): + data.append((sender, origin)) + + Person.objects.create(first_name='John', last_name='Smith') + book = Book.objects.create(name='Rayuela') + Page.objects.create(text='Page 1', book=book) + Page.objects.create(text='Page 2', book=book) + + signals.pre_delete.connect(pre_delete_handler, weak=False) + signals.post_delete.connect(post_delete_handler, weak=False) + try: + # Queryset deletion. + qs = Person.objects.all() + qs.delete() + self.assertEqual(data, [(Person, qs), (Person, qs)]) + data[:] = [] + # Cascade deletion. + qs = Book.objects.all() + qs.delete() + self.assertEqual(data, [ + (Page, qs), + (Page, qs), + (Book, qs), + (Page, qs), + (Page, qs), + (Book, qs), + ]) + finally: + signals.pre_delete.disconnect(pre_delete_handler) + signals.post_delete.disconnect(post_delete_handler) + def test_decorators(self): data = []