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
This commit is contained in:
Russell Keith-Magee 2010-01-13 11:07:16 +00:00
parent f56f6e9405
commit 6afd505b5b
6 changed files with 403 additions and 6 deletions

View File

@ -427,7 +427,8 @@ def create_many_related_manager(superclass, rel=False):
through = rel.through through = rel.through
class ManyRelatedManager(superclass): class ManyRelatedManager(superclass):
def __init__(self, model=None, core_filters=None, instance=None, symmetrical=None, 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__() super(ManyRelatedManager, self).__init__()
self.core_filters = core_filters self.core_filters = core_filters
self.model = model self.model = model
@ -437,6 +438,7 @@ def create_many_related_manager(superclass, rel=False):
self.target_field_name = target_field_name self.target_field_name = target_field_name
self.through = through self.through = through
self._pk_val = self.instance.pk self._pk_val = self.instance.pk
self.reverse = reverse
if self._pk_val is None: 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__) 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, source_field_name: self._pk_val,
'%s__in' % target_field_name: new_ids, '%s__in' % target_field_name: new_ids,
}) })
vals = set(vals) new_ids = new_ids - set(vals)
# Add the ones that aren't there already # 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(**{ self.through._default_manager.using(self.instance._state.db).create(**{
'%s_id' % source_field_name: self._pk_val, '%s_id' % source_field_name: self._pk_val,
'%s_id' % target_field_name: obj_id, '%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): def _remove_items(self, source_field_name, target_field_name, *objs):
# source_col_name: the PK colname in join_table for the source object # 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, source_field_name: self._pk_val,
'%s__in' % target_field_name: old_ids '%s__in' % target_field_name: old_ids
}).delete() }).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): def _clear_items(self, source_field_name):
# source_col_name: the PK colname in join_table for the source object # 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(**{ self.through._default_manager.using(self.instance._state.db).filter(**{
source_field_name: self._pk_val source_field_name: self._pk_val
}).delete() }).delete()
@ -579,7 +598,8 @@ class ManyRelatedObjectsDescriptor(object):
instance=instance, instance=instance,
symmetrical=False, symmetrical=False,
source_field_name=self.related.field.m2m_reverse_field_name(), 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 return manager
@ -596,6 +616,7 @@ class ManyRelatedObjectsDescriptor(object):
manager.clear() manager.clear()
manager.add(*value) manager.add(*value)
class ReverseManyRelatedObjectsDescriptor(object): class ReverseManyRelatedObjectsDescriptor(object):
# This class provides the functionality that makes the related-object # This class provides the functionality that makes the related-object
# managers available as attributes on a model class, for fields that have # managers available as attributes on a model class, for fields that have
@ -629,7 +650,8 @@ class ReverseManyRelatedObjectsDescriptor(object):
instance=instance, instance=instance,
symmetrical=(self.field.rel.symmetrical and isinstance(instance, rel_model)), symmetrical=(self.field.rel.symmetrical and isinstance(instance, rel_model)),
source_field_name=self.field.m2m_field_name(), 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 return manager

View File

@ -12,3 +12,5 @@ pre_delete = Signal(providing_args=["instance"])
post_delete = Signal(providing_args=["instance"]) post_delete = Signal(providing_args=["instance"])
post_syncdb = Signal(providing_args=["class", "app", "created_models", "verbosity", "interactive"]) post_syncdb = Signal(providing_args=["class", "app", "created_models", "verbosity", "interactive"])
m2m_changed = Signal(providing_args=["action", "instance", "reverse", "model", "pk_set"])

View File

@ -170,6 +170,123 @@ Arguments sent with this signal:
Note that the object will no longer be in the database, so be very Note that the object will no longer be in the database, so be very
careful what you do with this instance. 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 class_prepared
-------------- --------------

View File

@ -29,6 +29,9 @@ notifications:
Sent before or after a model's :meth:`~django.db.models.Model.delete` Sent before or after a model's :meth:`~django.db.models.Model.delete`
method is called. 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_started` &
:data:`django.core.signals.request_finished` :data:`django.core.signals.request_finished`

View File

@ -0,0 +1 @@

View File

@ -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: <class 'modeltests.m2m_signals.models.Part'>
objects: [<Part: Doors>, <Part: Engine>, <Part: Wheelset>]
# 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: <class 'modeltests.m2m_signals.models.Car'>
objects: [<Car: BMW>, <Car: Toyota>]
# 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: <class 'modeltests.m2m_signals.models.Part'>
objects: [<Part: Airbag>, <Part: Engine>]
# 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: <class 'modeltests.m2m_signals.models.Part'>
objects: [<Part: Airbag>, <Part: Sunroof>]
# 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: <class 'modeltests.m2m_signals.models.Car'>
objects: [<Car: BMW>, <Car: Toyota>]
# 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: <class 'modeltests.m2m_signals.models.Car'>
objects: [<Car: VW>]
# clear all parts of the VW
>>> c1.default_parts.clear()
m2m_changed signal
instance: VW
action: clear
reverse: False
model: <class 'modeltests.m2m_signals.models.Part'>
# take all the doors off of cars
>>> p2.car_set.clear()
m2m_changed signal
instance: Doors
action: clear
reverse: True
model: <class 'modeltests.m2m_signals.models.Car'>
# 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: <class 'modeltests.m2m_signals.models.Car'>
# alternative ways of setting relation:
>>> c1.default_parts.create(name='Windows')
m2m_changed signal
instance: VW
action: add
reverse: False
model: <class 'modeltests.m2m_signals.models.Part'>
objects: [<Part: Windows>]
<Part: Windows>
# direct assignment clears the set first, then adds
>>> c1.default_parts = [p1,p2,p3]
m2m_changed signal
instance: VW
action: clear
reverse: False
model: <class 'modeltests.m2m_signals.models.Part'>
m2m_changed signal
instance: VW
action: add
reverse: False
model: <class 'modeltests.m2m_signals.models.Part'>
objects: [<Part: Doors>, <Part: Engine>, <Part: Wheelset>]
# 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: <class 'modeltests.m2m_signals.models.Part'>
m2m_changed signal
instance: Bugatti
action: add
reverse: False
model: <class 'modeltests.m2m_signals.models.Part'>
objects: [<Part: Doors>]
>>> p3.car_set.add(c4)
m2m_changed signal
instance: Engine
action: add
reverse: True
model: <class 'modeltests.m2m_signals.models.Car'>
objects: [<Car: Bugatti>]
# 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: <class 'modeltests.m2m_signals.models.Person'>
m2m_changed signal
instance: Alice
action: add
reverse: False
model: <class 'modeltests.m2m_signals.models.Person'>
objects: [<Person: Bob>, <Person: Chuck>]
>>> p1.fans = [p4]
m2m_changed signal
instance: Alice
action: clear
reverse: False
model: <class 'modeltests.m2m_signals.models.Person'>
m2m_changed signal
instance: Alice
action: add
reverse: False
model: <class 'modeltests.m2m_signals.models.Person'>
objects: [<Person: Daisy>]
>>> p3.idols = [p1,p2]
m2m_changed signal
instance: Chuck
action: clear
reverse: True
model: <class 'modeltests.m2m_signals.models.Person'>
m2m_changed signal
instance: Chuck
action: add
reverse: True
model: <class 'modeltests.m2m_signals.models.Person'>
objects: [<Person: Alice>, <Person: Bob>]
# 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)
"""}