Repository: stephenmcd/mezzanine

Issue:
Exceptions in get_ascendants
for some reason, I am not sure how to reproduce though (happens only with the prod database..) 
I get the following exception when get ascendents of a page in a template : 

... 

`File "/opt/mymedecine/venv/lib/python3.4/site-packages/django/template/base.py", line 850, in resolve
    value = self._resolve_lookup(context)
  File "/opt/mymedecine/venv/lib/python3.4/site-packages/django/template/base.py", line 913, in _resolve_lookup
    current = current()
  File "/opt/mymedecine/venv/lib/python3.4/site-packages/mezzanine/pages/models.py", line 121, in get_ascendants
    self._ascendants = pages[0]._ascendants
`



Discussion:
The traceback is missing the actual exception.

Does this happen with any page or just some? Only pages with parents? Only pages with custom slugs?

This must be happening due to some particular arrangement of your page tree and slugs, so we'll really need more detail to work out what's going on here.

Your fix might be all that's needed, but until we know what's causing the problem we can't know that for sure.

Ok, I finally got some time to investigate this one. pheww 

I was calling the method, on a page that's **not on the current site** . 
I have something like : 

```
class ItemViews(models.Model):
    # ... 
    item = models.ForeignKey(Page) # this is not filtered by current site.
```

And in my code ( and the production instance is of course multi site), I somehow retrieve all ItemViews for current user, and try to call get_ascendants on them, which triggers the exception, if the related page is not on the current site. I fixed the initial issue by having a proper custom manager for my ItemsView model that filters only items whose attached Page is on the current site, but I believe my proposed PR is still valid, we are calling [0] on a list we know may be empty ...  


Resolving patch:
diff --git a/mezzanine/pages/models.py b/mezzanine/pages/models.py
--- a/mezzanine/pages/models.py
+++ b/mezzanine/pages/models.py
@@ -1,5 +1,7 @@
 from __future__ import unicode_literals
 from future.builtins import str
+from mezzanine.utils.sites import override_current_site_id
+
 try:
     from urllib.parse import urljoin
 except ImportError:  # Python 2
@@ -115,8 +117,9 @@ def get_ascendants(self, for_user=None):
             # have not been customised.
             if self.slug:
                 kwargs = {"for_user": for_user}
-                pages = Page.objects.with_ascendants_for_slug(self.slug,
-                                                              **kwargs)
+                with override_current_site_id(self.site_id):
+                    pages = Page.objects.with_ascendants_for_slug(self.slug,
+                                                                  **kwargs)
                 self._ascendants = pages[0]._ascendants
             else:
                 self._ascendants = []
diff --git a/mezzanine/utils/sites.py b/mezzanine/utils/sites.py
--- a/mezzanine/utils/sites.py
+++ b/mezzanine/utils/sites.py
@@ -2,6 +2,8 @@
 
 import os
 import sys
+import threading
+from contextlib import contextmanager
 
 from django.contrib.sites.models import Site
 
@@ -12,21 +14,26 @@
 def current_site_id():
     """
     Responsible for determining the current ``Site`` instance to use
-    when retrieving data for any ``SiteRelated`` models. If a request
-    is available, and the site can be determined from it, we store the
-    site against the request for subsequent retrievals. Otherwise the
-    order of checks is as follows:
+    when retrieving data for any ``SiteRelated`` models. If we're inside an
+    override_current_site_id context manager, return the overriding site ID.
+    Otherwise, try to determine the site using the following methods in order:
 
       - ``site_id`` in session. Used in the admin so that admin users
         can switch sites and stay on the same domain for the admin.
-      - host for the current request matched to the domain of the site
-        instance.
+      - The id of the Site object corresponding to the hostname in the current
+        request. This result is cached.
       - ``MEZZANINE_SITE_ID`` environment variable, so management
         commands or anything else outside of a request can specify a
         site.
       - ``SITE_ID`` setting.
 
+    If a current request exists and the current site is not overridden, the
+    site ID is stored on the request object to speed up subsequent calls.
     """
+
+    if hasattr(override_current_site_id.thread_local, "site_id"):
+        return override_current_site_id.thread_local.site_id
+
     from mezzanine.utils.cache import cache_installed, cache_get, cache_set
     request = current_request()
     site_id = getattr(request, "site_id", None)
@@ -59,6 +66,18 @@ def current_site_id():
     return site_id
 
 
+@contextmanager
+def override_current_site_id(site_id):
+    """
+    Context manager that overrides the current site id for code executed
+    within it. Used to access SiteRelated objects outside the current site.
+    """
+    override_current_site_id.thread_local.site_id = site_id
+    yield
+    del override_current_site_id.thread_local.site_id
+override_current_site_id.thread_local = threading.local()
+
+
 def has_site_permission(user):
     """
     Checks if a staff user has staff-level access for the current site.


Test patch:
diff --git a/mezzanine/core/tests.py b/mezzanine/core/tests.py
--- a/mezzanine/core/tests.py
+++ b/mezzanine/core/tests.py
@@ -6,6 +6,7 @@
 from mezzanine.core.middleware import FetchFromCacheMiddleware
 from mezzanine.core.templatetags.mezzanine_tags import initialize_nevercache
 from mezzanine.utils.cache import cache_installed
+from mezzanine.utils.sites import current_site_id, override_current_site_id
 
 try:
     # Python 3
@@ -559,6 +560,12 @@ def test_update_site(self):
         site1.delete()
         site2.delete()
 
+    def test_override_site_id(self):
+        self.assertEqual(current_site_id(), 1)
+        with override_current_site_id(2):
+            self.assertEqual(current_site_id(), 2)
+        self.assertEqual(current_site_id(), 1)
+
 
 class CSRFTestViews(object):
     def nevercache_view(request):
diff --git a/mezzanine/pages/tests.py b/mezzanine/pages/tests.py
--- a/mezzanine/pages/tests.py
+++ b/mezzanine/pages/tests.py
@@ -1,4 +1,6 @@
 from __future__ import unicode_literals
+
+from django.contrib.sites.models import Site
 from future.builtins import str
 
 from unittest import skipUnless
@@ -20,6 +22,7 @@
 from mezzanine.pages.models import Page, RichTextPage
 from mezzanine.pages.admin import PageAdminForm
 from mezzanine.urls import PAGES_SLUG
+from mezzanine.utils.sites import override_current_site_id
 from mezzanine.utils.tests import TestCase
 
 
@@ -28,6 +31,16 @@
 
 class PagesTests(TestCase):
 
+    def setUp(self):
+        """
+        Make sure we have a thread-local request with a site_id attribute set.
+        """
+        super(PagesTests, self).setUp()
+        from mezzanine.core.request import _thread_local
+        request = self._request_factory.get('/')
+        request.site_id = settings.SITE_ID
+        _thread_local.request = request
+
     def test_page_ascendants(self):
         """
         Test the methods for looking up ascendants efficiently
@@ -37,8 +50,6 @@ def test_page_ascendants(self):
         primary, created = RichTextPage.objects.get_or_create(title="Primary")
         secondary, created = primary.children.get_or_create(title="Secondary")
         tertiary, created = secondary.children.get_or_create(title="Tertiary")
-        # Force a site ID to avoid the site query when measuring queries.
-        setattr(current_request(), "site_id", settings.SITE_ID)
 
         # Test that get_ascendants() returns the right thing.
         page = Page.objects.get(id=tertiary.id)
@@ -374,3 +385,17 @@ class Meta:
         submitted_form = TestPageAdminForm(data=data)
         self.assertTrue(submitted_form.is_valid())
         self.assertEqual(submitted_form.cleaned_data['slug'], 'hello/world')
+
+    def test_ascendants_different_site(self):
+        site2 = Site.objects.create(domain='site2.example.com', name='Site 2')
+
+        parent = Page.objects.create(title="Parent", site=site2)
+        child = parent.children.create(title="Child", site=site2)
+        grandchild = child.children.create(title="Grandchild", site=site2)
+
+        # Re-retrieve grandchild so its parent attribute is not cached
+        with override_current_site_id(site2.id):
+            grandchild = Page.objects.get(pk=grandchild.pk)
+
+        with self.assertNumQueries(1):
+            self.assertListEqual(grandchild.get_ascendants(), [child, parent])