From 6afd505b5be528bc5728bc9b9acd48276be457b4 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 13 Jan 2010 11:07:16 +0000 Subject: [PATCH] Fixed #5390 -- Added signals for m2m operations. Thanks to the many people (including, most recently, rvdrijst and frans) that have contributed to this patch. git-svn-id: http://code.djangoproject.com/svn/django/trunk@12223 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/db/models/fields/related.py | 34 ++- django/db/models/signals.py | 2 + docs/ref/signals.txt | 117 +++++++++++ docs/topics/signals.txt | 3 + tests/modeltests/m2m_signals/__init__.py | 1 + tests/modeltests/m2m_signals/models.py | 252 +++++++++++++++++++++++ 6 files changed, 403 insertions(+), 6 deletions(-) create mode 100644 tests/modeltests/m2m_signals/__init__.py create mode 100644 tests/modeltests/m2m_signals/models.py diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 857222f048..f3962f17b2 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -427,7 +427,8 @@ def create_many_related_manager(superclass, rel=False): through = rel.through class ManyRelatedManager(superclass): def __init__(self, model=None, core_filters=None, instance=None, symmetrical=None, - join_table=None, source_field_name=None, target_field_name=None): + join_table=None, source_field_name=None, target_field_name=None, + reverse=False): super(ManyRelatedManager, self).__init__() self.core_filters = core_filters self.model = model @@ -437,6 +438,7 @@ def create_many_related_manager(superclass, rel=False): self.target_field_name = target_field_name self.through = through self._pk_val = self.instance.pk + self.reverse = reverse if self._pk_val is None: raise ValueError("%r instance needs to have a primary key value before a many-to-many relationship can be used." % instance.__class__.__name__) @@ -516,14 +518,19 @@ def create_many_related_manager(superclass, rel=False): source_field_name: self._pk_val, '%s__in' % target_field_name: new_ids, }) - vals = set(vals) - + new_ids = new_ids - set(vals) # Add the ones that aren't there already - for obj_id in (new_ids - vals): + for obj_id in new_ids: self.through._default_manager.using(self.instance._state.db).create(**{ '%s_id' % source_field_name: self._pk_val, '%s_id' % target_field_name: obj_id, }) + if self.reverse or source_field_name == self.source_field_name: + # Don't send the signal when we are inserting the + # duplicate data row for symmetrical reverse entries. + signals.m2m_changed.send(sender=rel.through, action='add', + instance=self.instance, reverse=self.reverse, + model=self.model, pk_set=new_ids) def _remove_items(self, source_field_name, target_field_name, *objs): # source_col_name: the PK colname in join_table for the source object @@ -544,9 +551,21 @@ def create_many_related_manager(superclass, rel=False): source_field_name: self._pk_val, '%s__in' % target_field_name: old_ids }).delete() + if self.reverse or source_field_name == self.source_field_name: + # Don't send the signal when we are deleting the + # duplicate data row for symmetrical reverse entries. + signals.m2m_changed.send(sender=rel.through, action="remove", + instance=self.instance, reverse=self.reverse, + model=self.model, pk_set=old_ids) def _clear_items(self, source_field_name): # source_col_name: the PK colname in join_table for the source object + if self.reverse or source_field_name == self.source_field_name: + # Don't send the signal when we are clearing the + # duplicate data rows for symmetrical reverse entries. + signals.m2m_changed.send(sender=rel.through, action="clear", + instance=self.instance, reverse=self.reverse, + model=self.model, pk_set=None) self.through._default_manager.using(self.instance._state.db).filter(**{ source_field_name: self._pk_val }).delete() @@ -579,7 +598,8 @@ class ManyRelatedObjectsDescriptor(object): instance=instance, symmetrical=False, source_field_name=self.related.field.m2m_reverse_field_name(), - target_field_name=self.related.field.m2m_field_name() + target_field_name=self.related.field.m2m_field_name(), + reverse=True ) return manager @@ -596,6 +616,7 @@ class ManyRelatedObjectsDescriptor(object): manager.clear() manager.add(*value) + class ReverseManyRelatedObjectsDescriptor(object): # This class provides the functionality that makes the related-object # managers available as attributes on a model class, for fields that have @@ -629,7 +650,8 @@ class ReverseManyRelatedObjectsDescriptor(object): instance=instance, symmetrical=(self.field.rel.symmetrical and isinstance(instance, rel_model)), source_field_name=self.field.m2m_field_name(), - target_field_name=self.field.m2m_reverse_field_name() + target_field_name=self.field.m2m_reverse_field_name(), + reverse=False ) return manager diff --git a/django/db/models/signals.py b/django/db/models/signals.py index 045533612d..cd0350bc01 100644 --- a/django/db/models/signals.py +++ b/django/db/models/signals.py @@ -12,3 +12,5 @@ pre_delete = Signal(providing_args=["instance"]) post_delete = Signal(providing_args=["instance"]) post_syncdb = Signal(providing_args=["class", "app", "created_models", "verbosity", "interactive"]) + +m2m_changed = Signal(providing_args=["action", "instance", "reverse", "model", "pk_set"]) diff --git a/docs/ref/signals.txt b/docs/ref/signals.txt index b54a895000..54efbff89e 100644 --- a/docs/ref/signals.txt +++ b/docs/ref/signals.txt @@ -170,6 +170,123 @@ Arguments sent with this signal: Note that the object will no longer be in the database, so be very careful what you do with this instance. +m2m_changed +----------- + +.. data:: django.db.models.signals.m2m_changed + :module: + +Sent when a :class:`ManyToManyField` is changed on a model instance. +Strictly speaking, this is not a model signal since it is sent by the +:class:`ManyToManyField`, but since it complements the +:data:`pre_save`/:data:`post_save` and :data:`pre_delete`/:data:`post_delete` +when it comes to tracking changes to models, it is included here. + +Arguments sent with this signal: + + ``sender`` + The intermediate model class describing the :class:`ManyToManyField`. + This class is automatically created when a many-to-many field is + defined; it you can access it using the ``through`` attribute on the + many-to-many field. + + ``instance`` + The instance whose many-to-many relation is updated. This can be an + instance of the ``sender``, or of the class the :class:`ManyToManyField` + is related to. + + ``action`` + A string indicating the type of update that is done on the relation. + This can be one of the following: + + ``"add"`` + Sent *after* one or more objects are added to the relation + ``"remove"`` + Sent *after* one or more objects are removed from the relation + ``"clear"`` + Sent *before* the relation is cleared + + ``reverse`` + Indicates which side of the relation is updated (i.e., if it is the + forward or reverse relation that is being modified). + + ``model`` + The class of the objects that are added to, removed from or cleared + from the relation. + + ``pk_set`` + With the ``"add"`` and ``"remove"`` action, this is a list of + primary key values that have been added to or removed from the relation. + + For the ``"clear"`` action, this is ``None``. + +For example, if a ``Pizza`` can have multiple ``Topping`` objects, modeled +like this: + +.. code-block:: python + + class Topping(models.Model): + # ... + + class Pizza(models.Model): + # ... + toppings = models.ManyToManyField(Topping) + +If we would do something like this: + +.. code-block:: python + + >>> p = Pizza.object.create(...) + >>> t = Topping.objects.create(...) + >>> p.toppings.add(t) + +the arguments sent to a :data:`m2m_changed` handler would be: + + ============== ============================================================ + Argument Value + ============== ============================================================ + ``sender`` ``Pizza.toppings.through`` (the intermediate m2m class) + + ``instance`` ``p`` (the ``Pizza`` instance being modified) + + ``action`` ``"add"`` + + ``reverse`` ``False`` (``Pizza`` contains the :class:`ManyToManyField`, + so this call modifies the forward relation) + + ``model`` ``Topping`` (the class of the objects added to the + ``Pizza``) + + ``pk_set`` ``[t.id]`` (since only ``Topping t`` was added to the relation) + ============== ============================================================ + +And if we would then do something like this: + +.. code-block:: python + + >>> t.pizza_set.remove(p) + +the arguments sent to a :data:`m2m_changed` handler would be: + + ============== ============================================================ + Argument Value + ============== ============================================================ + ``sender`` ``Pizza.toppings.through`` (the intermediate m2m class) + + ``instance`` ``t`` (the ``Topping`` instance being modified) + + ``action`` ``"remove"`` + + ``reverse`` ``True`` (``Pizza`` contains the :class:`ManyToManyField`, + so this call modifies the reverse relation) + + ``model`` ``Pizza`` (the class of the objects removed from the + ``Topping``) + + ``pk_set`` ``[p.id]`` (since only ``Pizza p`` was removed from the + relation) + ============== ============================================================ + class_prepared -------------- diff --git a/docs/topics/signals.txt b/docs/topics/signals.txt index 6f66b036c7..e3f5b90d89 100644 --- a/docs/topics/signals.txt +++ b/docs/topics/signals.txt @@ -29,6 +29,9 @@ notifications: Sent before or after a model's :meth:`~django.db.models.Model.delete` method is called. + * :data:`django.db.models.signals.m2m_changed` + + Sent when a :class:`ManyToManyField` on a model is changed. * :data:`django.core.signals.request_started` & :data:`django.core.signals.request_finished` diff --git a/tests/modeltests/m2m_signals/__init__.py b/tests/modeltests/m2m_signals/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tests/modeltests/m2m_signals/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/modeltests/m2m_signals/models.py b/tests/modeltests/m2m_signals/models.py new file mode 100644 index 0000000000..3adcd796e3 --- /dev/null +++ b/tests/modeltests/m2m_signals/models.py @@ -0,0 +1,252 @@ +""" +Testing signals emitted on changing m2m relations. +""" + +from django.db import models + +class Part(models.Model): + name = models.CharField(max_length=20) + + class Meta: + ordering = ('name',) + + def __unicode__(self): + return self.name + +class Car(models.Model): + name = models.CharField(max_length=20) + default_parts = models.ManyToManyField(Part) + optional_parts = models.ManyToManyField(Part, related_name='cars_optional') + + class Meta: + ordering = ('name',) + + def __unicode__(self): + return self.name + +class SportsCar(Car): + price = models.IntegerField() + +class Person(models.Model): + name = models.CharField(max_length=20) + fans = models.ManyToManyField('self', related_name='idols', symmetrical=False) + friends = models.ManyToManyField('self') + + class Meta: + ordering = ('name',) + + def __unicode__(self): + return self.name + +def m2m_changed_test(signal, sender, **kwargs): + print 'm2m_changed signal' + print 'instance:', kwargs['instance'] + print 'action:', kwargs['action'] + print 'reverse:', kwargs['reverse'] + print 'model:', kwargs['model'] + if kwargs['pk_set']: + print 'objects:',kwargs['model'].objects.filter(pk__in=kwargs['pk_set']) + + +__test__ = {'API_TESTS':""" +# Install a listener on one of the two m2m relations. +>>> models.signals.m2m_changed.connect(m2m_changed_test, Car.optional_parts.through) + +# Test the add, remove and clear methods on both sides of the +# many-to-many relation + +>>> c1 = Car.objects.create(name='VW') +>>> c2 = Car.objects.create(name='BMW') +>>> c3 = Car.objects.create(name='Toyota') +>>> p1 = Part.objects.create(name='Wheelset') +>>> p2 = Part.objects.create(name='Doors') +>>> p3 = Part.objects.create(name='Engine') +>>> p4 = Part.objects.create(name='Airbag') +>>> p5 = Part.objects.create(name='Sunroof') + +# adding a default part to our car - no signal listener installed +>>> c1.default_parts.add(p5) + +# Now install a listener +>>> models.signals.m2m_changed.connect(m2m_changed_test, Car.default_parts.through) + +>>> c1.default_parts.add(p1, p2, p3) +m2m_changed signal +instance: VW +action: add +reverse: False +model: +objects: [, , ] + +# give the BMW and Toyata some doors as well +>>> p2.car_set.add(c2, c3) +m2m_changed signal +instance: Doors +action: add +reverse: True +model: +objects: [, ] + +# remove the engine from the VW and the airbag (which is not set but is returned) +>>> c1.default_parts.remove(p3, p4) +m2m_changed signal +instance: VW +action: remove +reverse: False +model: +objects: [, ] + +# give the VW some optional parts (second relation to same model) +>>> c1.optional_parts.add(p4,p5) +m2m_changed signal +instance: VW +action: add +reverse: False +model: +objects: [, ] + +# add airbag to all the cars (even though the VW already has one) +>>> p4.cars_optional.add(c1, c2, c3) +m2m_changed signal +instance: Airbag +action: add +reverse: True +model: +objects: [, ] + +# remove airbag from the VW (reverse relation with custom related_name) +>>> p4.cars_optional.remove(c1) +m2m_changed signal +instance: Airbag +action: remove +reverse: True +model: +objects: [] + +# clear all parts of the VW +>>> c1.default_parts.clear() +m2m_changed signal +instance: VW +action: clear +reverse: False +model: + +# take all the doors off of cars +>>> p2.car_set.clear() +m2m_changed signal +instance: Doors +action: clear +reverse: True +model: + +# take all the airbags off of cars (clear reverse relation with custom related_name) +>>> p4.cars_optional.clear() +m2m_changed signal +instance: Airbag +action: clear +reverse: True +model: + +# alternative ways of setting relation: + +>>> c1.default_parts.create(name='Windows') +m2m_changed signal +instance: VW +action: add +reverse: False +model: +objects: [] + + +# direct assignment clears the set first, then adds +>>> c1.default_parts = [p1,p2,p3] +m2m_changed signal +instance: VW +action: clear +reverse: False +model: +m2m_changed signal +instance: VW +action: add +reverse: False +model: +objects: [, , ] + +# Check that signals still work when model inheritance is involved +>>> c4 = SportsCar.objects.create(name='Bugatti', price='1000000') +>>> c4.default_parts = [p2] +m2m_changed signal +instance: Bugatti +action: clear +reverse: False +model: +m2m_changed signal +instance: Bugatti +action: add +reverse: False +model: +objects: [] + +>>> p3.car_set.add(c4) +m2m_changed signal +instance: Engine +action: add +reverse: True +model: +objects: [] + +# Now test m2m relations with self +>>> p1 = Person.objects.create(name='Alice') +>>> p2 = Person.objects.create(name='Bob') +>>> p3 = Person.objects.create(name='Chuck') +>>> p4 = Person.objects.create(name='Daisy') + +>>> models.signals.m2m_changed.connect(m2m_changed_test, Person.fans.through) +>>> models.signals.m2m_changed.connect(m2m_changed_test, Person.friends.through) + +>>> p1.friends = [p2, p3] +m2m_changed signal +instance: Alice +action: clear +reverse: False +model: +m2m_changed signal +instance: Alice +action: add +reverse: False +model: +objects: [, ] + +>>> p1.fans = [p4] +m2m_changed signal +instance: Alice +action: clear +reverse: False +model: +m2m_changed signal +instance: Alice +action: add +reverse: False +model: +objects: [] + +>>> p3.idols = [p1,p2] +m2m_changed signal +instance: Chuck +action: clear +reverse: True +model: +m2m_changed signal +instance: Chuck +action: add +reverse: True +model: +objects: [, ] + +# Cleanup - disconnect all signal handlers +>>> models.signals.m2m_changed.disconnect(m2m_changed_test, Car.default_parts.through) +>>> models.signals.m2m_changed.disconnect(m2m_changed_test, Car.optional_parts.through) +>>> models.signals.m2m_changed.disconnect(m2m_changed_test, Person.fans.through) +>>> models.signals.m2m_changed.disconnect(m2m_changed_test, Person.friends.through) + +"""}