Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Fixed #5390 -- Added signals for m2m operations. Thanks to the many p…

…eople (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
  • Loading branch information...
commit 6afd505b5be528bc5728bc9b9acd48276be457b4 1 parent f56f6e9
@freakboy3742 freakboy3742 authored
View
34 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 __init__(self, model=None, core_filters=None, instance=None, symmetrical=Non
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 _add_items(self, source_field_name, target_field_name, *objs):
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 _remove_items(self, source_field_name, target_field_name, *objs):
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 @@ def __get__(self, instance, instance_type=None):
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 @@ def __set__(self, instance, value):
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 @@ def __get__(self, instance, instance_type=None):
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
View
2  django/db/models/signals.py
@@ -12,3 +12,5 @@
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"])
View
117 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
--------------
View
3  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`
View
1  tests/modeltests/m2m_signals/__init__.py
@@ -0,0 +1 @@
+
View
252 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: <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)
+
+"""}
Please sign in to comment.
Something went wrong with that request. Please try again.