Repository: stephenmcd/mezzanine

Issue:
Catch-all signals in mezzanine.generic disable fast deletion
Hi Steve,

Mezzanine's BaseGenericRelation connects its signals up without a sender with the following comment:

```
# For some unknown reason the signal won't be triggered
# if given a sender arg, particularly when running
# Cartridge with the field RichTextPage.keywords - so
# instead of specifying self.rel.to as the sender, we
# check for it inside the signal itself.
post_save.connect(self._related_items_changed)
post_delete.connect(self._related_items_changed)
```

Django has two paths for object deletion – a fast path which transforms the query into a `DELETE FROM`, and a slow one that retrieves the items and then deletes them. A condition of the fast path being used is that the model being deleted has no post_delete handler set up, so by listening for all post_delete signals mezzanine.generic effectively disables the fast path globally.

I've just run the tests with the sender specified on 1.4, 1.5 and 1.6 and everything is passing – if you can remember what wasn't working, or give any more info, I'd quite like to look into this further and see if we can't find another workaround.

As always thanks for your great work.



Discussion:
Hey Alex, I can't recall any info other than what I put in the comment there, I think that code must be a couple of years old - fortunately I think it gives us enough to go by. 

If we can see sender working for save/delete on RichTextPage.keywords when using Cartridge then whatever the root cause must have gone. 

Wouldn't be too surprised if it just works as is - BaseGenericRelation may have changed enough since that code was written to be doing things differently and not causing the problem. Perhaps it was for Django 1.3 or earlier.

I'm not sure what the test coverage is like across save/delete on all the BaseGenericRelation subclasses, but if it's not a lot this would be a good opportunity to add some.

Thanks Alex.

Alright - I'll try my hardest to break it, take a look at the tests and submit a pull request. Thanks!

Can confirm these is still an issue when using Cartridge. Still looking into it, will let you know what I find.

Cool - kind of a relief to know that the state of things are as expected.

If you move `cartridge.shop` to the bottom of `INSTALLED_APPS`, things work.

Also, bizarrely, if you add `sender=get_user_model()` to the line `post_save.connect(create_site_permission)` in mezzanine/core/models.py - things also work.

Still digging.


Resolving patch:
diff --git a/mezzanine/generic/fields.py b/mezzanine/generic/fields.py
--- a/mezzanine/generic/fields.py
+++ b/mezzanine/generic/fields.py
@@ -7,7 +7,57 @@
 from django.contrib.contenttypes.generic import GenericRelation
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import get_model, IntegerField, CharField, FloatField
-from django.db.models.signals import post_save, post_delete
+from django.db.models.signals import post_save, post_delete, class_prepared
+from django.utils import six
+
+
+class LazyModelOperations(object):
+    """
+    This class connects itself to Django's class_prepared signal. Pass a
+    function and a model or model name to its add() method, and the function
+    will be called with the model as its only parameter once the model has been
+    loaded. If the model is already loaded, the function is called immediately.
+
+    Adapted from code found at the top of django/db/models/fields/related.py.
+    """
+    def __init__(self):
+        self.pending_operations = {}
+        class_prepared.connect(self.signal_receiver)
+
+    @staticmethod
+    def model_key(model_or_name):
+        """
+        Returns an (app_label, model_name) tuple from a model or string.
+        """
+        if isinstance(model_or_name, six.string_types):
+            app_label, model_name = model_or_name.split(".")
+        else:
+            # it's actually a model class
+            app_label = model_or_name._meta.app_label
+            model_name = model_or_name._meta.object_name
+        return app_label, model_name
+
+    def add(self, function, model_or_name):
+        model_key = self.model_key(model_or_name)
+
+        # If the model is already loaded, pass it to the function immediately.
+        # Otherwise, delay execution until the class is prepared.
+        model = get_model(*model_key, seed_cache=False, only_installed=False)
+        if model:
+            function(model)
+        else:
+            self.pending_operations.setdefault(model_key, []).append(function)
+
+    def signal_receiver(self, sender, **_):
+        """
+        Receive class_prepared, and pass the freshly prepared model to each
+        function waiting for it.
+        """
+        key = (sender._meta.app_label, sender.__name__)
+        for function in self.pending_operations.pop(key, []):
+            function(sender)
+
+lazy_model_ops = LazyModelOperations()
 
 
 class BaseGenericRelation(GenericRelation):
@@ -93,8 +143,15 @@ def contribute_to_class(self, cls, name):
             # Cartridge with the field RichTextPage.keywords - so
             # instead of specifying self.rel.to as the sender, we
             # check for it inside the signal itself.
-            post_save.connect(self._related_items_changed)
-            post_delete.connect(self._related_items_changed)
+
+            def connect_save(sender):
+                post_save.connect(self._related_items_changed, sender=sender)
+
+            def connect_delete(sender):
+                post_delete.connect(self._related_items_changed, sender=sender)
+
+            lazy_model_ops.add(connect_save, self.rel.to)
+            lazy_model_ops.add(connect_delete, self.rel.to)
 
     def _related_items_changed(self, **kwargs):
         """
@@ -102,16 +159,6 @@ def _related_items_changed(self, **kwargs):
         this field applies to, and pass the instance to the real
         ``related_items_changed`` handler.
         """
-        # Manually check that the instance matches the relation,
-        # since we don't specify a sender for the signal.
-        try:
-            to = self.rel.to
-            if isinstance(to, str):
-                to = get_model(*to.split(".", 1))
-            if not isinstance(kwargs["instance"], to):
-                raise TypeError
-        except (TypeError, ValueError):
-            return
         for_model = kwargs["instance"].content_type.model_class()
         if issubclass(for_model, self.model):
             instance_id = kwargs["instance"].object_pk


Test patch:
diff --git a/mezzanine/generic/tests.py b/mezzanine/generic/tests.py
--- a/mezzanine/generic/tests.py
+++ b/mezzanine/generic/tests.py
@@ -10,7 +10,8 @@
 
 from mezzanine.core.models import CONTENT_STATUS_PUBLISHED
 from mezzanine.generic.forms import RatingForm
-from mezzanine.generic.models import AssignedKeyword, Keyword, ThreadedComment
+from mezzanine.generic.models import (AssignedKeyword, Keyword,
+                                      ThreadedComment, Rating)
 from mezzanine.pages.models import RichTextPage
 from mezzanine.utils.tests import TestCase
 
@@ -40,6 +41,28 @@ def test_rating(self):
         self.assertEqual(blog_post.rating_sum, _sum)
         self.assertEqual(blog_post.rating_average, average)
 
+    @skipUnless("mezzanine.blog" in settings.INSTALLED_APPS,
+                "blog app required")
+    def test_comment_ratings(self):
+        """
+        Test that a generic relation defined on one of Mezzanine's generic
+        models (in this case ratings of comments) correctly sets its
+        extra fields.
+        """
+        blog_post = BlogPost.objects.create(title="Post with comments",
+                                            user=self._user)
+        content_type = ContentType.objects.get_for_model(blog_post)
+        kwargs = {"content_type": content_type, "object_pk": blog_post.id,
+                  "site_id": settings.SITE_ID, "comment": "First!!!11"}
+        comment = ThreadedComment.objects.create(**kwargs)
+        comment.rating.create(value=3)
+        comment.rating.add(Rating(value=5))
+        comment = ThreadedComment.objects.get(pk=comment.pk)
+
+        self.assertEqual(len(comment.rating.all()), comment.rating_count)
+
+        self.assertEqual(comment.rating_average, 4)
+
     @skipUnless("mezzanine.blog" in settings.INSTALLED_APPS,
                 "blog app required")
     def test_comment_queries(self):