diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 5a0a14abd..7d2a7494d 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -5,6 +5,7 @@ Changelog ~~~~~~~~~~~~~~~~ * Added toolbar to allow logged-in users to add and edit pages from the site front-end * Support for alternative image processing backends such as Wand, via the WAGTAILIMAGES_BACKENDS setting + * Added custom Query set for Pages with some handy methods for querying pages * Editor's guide documentation * Editor interface now outputs form media CSS / JS, to support custom widgets with assets * Migrations and user management now correctly handle custom AUTH_USER_MODEL settings diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index b06137b9d..8c6c55335 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -14,6 +14,7 @@ from django.template.response import TemplateResponse from django.utils.translation import ugettext_lazy as _ from wagtail.wagtailcore.util import camelcase_to_underscore +from wagtail.wagtailcore.query import PageQuerySet from wagtail.wagtailsearch import Indexed, get_search_backend @@ -128,6 +129,59 @@ def get_navigable_page_content_type_ids(): return _NAVIGABLE_PAGE_CONTENT_TYPE_IDS +class PageManager(models.Manager): + def get_query_set(self): + return PageQuerySet(self.model).order_by('path') + + def live(self): + return self.get_query_set().live() + + def not_live(self): + return self.get_query_set().not_live() + + def page(self, other): + return self.get_query_set().page(other) + + def not_page(self, other): + return self.get_query_set().not_page(other) + + def descendant_of(self, other, inclusive=False): + return self.get_query_set().descendant_of(other, inclusive) + + def not_descendant_of(self, other, inclusive=False): + return self.get_query_set().not_descendant_of(other, inclusive) + + def child_of(self, other): + return self.get_query_set().child_of(other) + + def not_child_of(self, other): + return self.get_query_set().not_child_of(other) + + def ancestor_of(self, other, inclusive=False): + return self.get_query_set().ancestor_of(other, inclusive) + + def not_ancestor_of(self, other, inclusive=False): + return self.get_query_set().not_ancestor_of(other, inclusive) + + def parent_of(self, other): + return self.get_query_set().parent_of(other) + + def not_parent_of(self, other): + return self.get_query_set().not_parent_of(other) + + def sibling_of(self, other, inclusive=False): + return self.get_query_set().sibling_of(other, inclusive) + + def not_sibling_of(self, other, inclusive=False): + return self.get_query_set().not_sibling_of(other, inclusive) + + def type(self, model): + return self.get_query_set().type(model) + + def not_type(self, model): + return self.get_query_set().not_type(model) + + class PageBase(models.base.ModelBase): """Metaclass for Page""" def __init__(cls, name, bases, dct): @@ -138,6 +192,9 @@ class PageBase(models.base.ModelBase): # don't proceed with all this page type registration stuff return + # Add page manager + PageManager().contribute_to_class(cls, 'objects') + if 'template' not in dct: # Define a default template path derived from the app name and model name cls.template = "%s/%s.html" % (cls._meta.app_label, camelcase_to_underscore(name)) diff --git a/wagtail/wagtailcore/query.py b/wagtail/wagtailcore/query.py new file mode 100644 index 000000000..d182ec588 --- /dev/null +++ b/wagtail/wagtailcore/query.py @@ -0,0 +1,109 @@ +from django.db.models import Q +from django.contrib.contenttypes.models import ContentType + + +# hack to import our patched copy of treebeard at wagtail/vendor/django-treebeard - +# based on http://stackoverflow.com/questions/17211078/how-to-temporarily-modify-sys-path-in-python +import sys +import os +treebeard_path = os.path.join(os.path.dirname(__file__), '..', 'vendor', 'django-treebeard') +sys.path.insert(0, treebeard_path) +from treebeard.mp_tree import MP_NodeQuerySet +sys.path.pop(0) + + +class PageQuerySet(MP_NodeQuerySet): + """ + Defines some extra query set methods that are useful for pages. + """ + def live_q(self): + return Q(live=True) + + def live(self): + return self.filter(self.live_q()) + + def not_live(self): + return self.exclude(self.live_q()) + + def page_q(self, other): + return Q(id=other.id) + + def page(self, other): + return self.filter(self.page_q(other)) + + def not_page(self, other): + return self.exclude(self.page_q(other)) + + def descendant_of_q(self, other, inclusive=False): + q = Q(path__startswith=other.path) & Q(depth__gte=other.depth) + + if not inclusive: + q &= ~self.page_q(other) + + return q + + def descendant_of(self, other, inclusive=False): + return self.filter(self.descendant_of_q(other, inclusive)) + + def not_descendant_of(self, other, inclusive=False): + return self.exclude(self.descendant_of_q(other, inclusive)) + + def child_of_q(self, other): + return self.descendant_of_q(other) & Q(depth=other.depth + 1) + + def child_of(self, other): + return self.filter(self.child_of_q(other)) + + def not_child_of(self, other): + return self.exclude(self.child_of_q(other)) + + def ancestor_of_q(self, other, inclusive=False): + paths = [ + other.path[0:pos] + for pos in range(0, len(other.path) + 1, other.steplen)[1:] + ] + q = Q(path__in=paths) + + if not inclusive: + q &= ~self.page_q(other) + + return q + + def ancestor_of(self, other, inclusive=False): + return self.filter(self.ancestor_of_q(other, inclusive)) + + def not_ancestor_of(self, other, inclusive=False): + return self.exclude(self.ancestor_of_q(other, inclusive)) + + def parent_of_q(self, other): + return Q(path=self.model._get_parent_path_from_path(other.path)) + + def parent_of(self, other): + return self.filter(self.parent_of_q(other)) + + def not_parent_of(self, other): + return self.exclude(self.parent_of_q(other)) + + def sibling_of_q(self, other, inclusive=False): + q = Q(path__startswith=self.model._get_parent_path_from_path(other.path)) & Q(depth=other.depth) + + if not inclusive: + q &= ~self.page_q(other) + + return q + + def sibling_of(self, other, inclusive=False): + return self.filter(self.sibling_of_q(other, inclusive)) + + def not_sibling_of(self, other, inclusive=False): + return self.exclude(self.sibling_of_q(other, inclusive)) + + def type_q(self, model): + content_type = ContentType.objects.get_for_model(model) + return Q(content_type=content_type) + + def type(self, model): + return self.filter(self.type_q(model)) + + def not_type(self, model): + return self.exclude(self.type_q(model)) diff --git a/wagtail/wagtailcore/tests.py b/wagtail/wagtailcore/tests.py index ce1b1ef90..b352dbc8f 100644 --- a/wagtail/wagtailcore/tests.py +++ b/wagtail/wagtailcore/tests.py @@ -335,3 +335,250 @@ class TestPagePermission(TestCase): self.assertTrue(homepage_perms.can_move_to(root)) self.assertFalse(homepage_perms.can_move_to(unpublished_event_page)) + + +class TestPageQuerySet(TestCase): + fixtures = ['test.json'] + + def test_live(self): + pages = Page.objects.live() + + # All pages must be live + for page in pages: + self.assertTrue(page.live) + + # Check that the homepage is in the results + homepage = Page.objects.get(url_path='/home/') + self.assertTrue(pages.filter(id=homepage.id).exists()) + + def test_not_live(self): + pages = Page.objects.not_live() + + # All pages must not be live + for page in pages: + self.assertFalse(page.live) + + # Check that "someone elses event" is in the results + event = Page.objects.get(url_path='/home/events/someone-elses-event/') + self.assertTrue(pages.filter(id=event.id).exists()) + + def test_page(self): + homepage = Page.objects.get(url_path='/home/') + pages = Page.objects.page(homepage) + + # Should only select the homepage + self.assertEqual(pages.count(), 1) + self.assertEqual(pages.first(), homepage) + + def test_not_page(self): + homepage = Page.objects.get(url_path='/home/') + pages = Page.objects.not_page(homepage) + + # Should select everything except for the homepage + self.assertEqual(pages.count(), Page.objects.all().count() - 1) + for page in pages: + self.assertNotEqual(page, homepage) + + def test_descendant_of(self): + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.descendant_of(events_index) + + # Check that all pages descend from events index + for page in pages: + self.assertTrue(page.get_ancestors().filter(id=events_index.id).exists()) + + def test_descendant_of_inclusive(self): + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.descendant_of(events_index, inclusive=True) + + # Check that all pages descend from events index, includes event index + for page in pages: + self.assertTrue(page == events_index or page.get_ancestors().filter(id=events_index.id).exists()) + + # Check that event index was included + self.assertTrue(pages.filter(id=events_index.id).exists()) + + def test_not_descendant_of(self): + homepage = Page.objects.get(url_path='/home/') + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.not_descendant_of(events_index) + + # Check that no pages descend from events_index + for page in pages: + self.assertFalse(page.get_ancestors().filter(id=events_index.id).exists()) + + # As this is not inclusive, events index should be in the results + self.assertTrue(pages.filter(id=events_index.id).exists()) + + def test_not_descendant_of_inclusive(self): + homepage = Page.objects.get(url_path='/home/') + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.not_descendant_of(events_index, inclusive=True) + + # Check that all pages descend from homepage but not events index + for page in pages: + self.assertFalse(page.get_ancestors().filter(id=events_index.id).exists()) + + # As this is inclusive, events index should not be in the results + self.assertFalse(pages.filter(id=events_index.id).exists()) + + def test_child_of(self): + homepage = Page.objects.get(url_path='/home/') + pages = Page.objects.child_of(homepage) + + # Check that all pages are children of homepage + for page in pages: + self.assertEqual(page.get_parent(), homepage) + + def test_not_child_of(self): + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.not_child_of(events_index) + + # Check that all pages are not children of events_index + for page in pages: + self.assertNotEqual(page.get_parent(), events_index) + + def test_ancestor_of(self): + root_page = Page.objects.get(id=1) + homepage = Page.objects.get(url_path='/home/') + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.ancestor_of(events_index) + + self.assertEqual(pages.count(), 2) + self.assertEqual(pages[0], root_page) + self.assertEqual(pages[1], homepage) + + def test_ancestor_of_inclusive(self): + root_page = Page.objects.get(id=1) + homepage = Page.objects.get(url_path='/home/') + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.ancestor_of(events_index, inclusive=True) + + self.assertEqual(pages.count(), 3) + self.assertEqual(pages[0], root_page) + self.assertEqual(pages[1], homepage) + self.assertEqual(pages[2], events_index) + + def test_not_ancestor_of(self): + root_page = Page.objects.get(id=1) + homepage = Page.objects.get(url_path='/home/') + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.not_ancestor_of(events_index) + + # Test that none of the ancestors are in pages + for page in pages: + self.assertNotEqual(page, root_page) + self.assertNotEqual(page, homepage) + + # Test that events index is in pages + self.assertTrue(pages.filter(id=events_index.id).exists()) + + def test_not_ancestor_of_inclusive(self): + root_page = Page.objects.get(id=1) + homepage = Page.objects.get(url_path='/home/') + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.not_ancestor_of(events_index, inclusive=True) + + # Test that none of the ancestors or the events_index are in pages + for page in pages: + self.assertNotEqual(page, root_page) + self.assertNotEqual(page, homepage) + self.assertNotEqual(page, events_index) + + def test_parent_of(self): + homepage = Page.objects.get(url_path='/home/') + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.parent_of(events_index) + + # Pages must only contain homepage + self.assertEqual(pages.count(), 1) + self.assertEqual(pages[0], homepage) + + def test_not_parent_of(self): + homepage = Page.objects.get(url_path='/home/') + events_index = Page.objects.get(url_path='/home/events/') + pages = Page.objects.not_parent_of(events_index) + + # Pages must not contain homepage + for page in pages: + self.assertNotEqual(page, homepage) + + # Test that events index is in pages + self.assertTrue(pages.filter(id=events_index.id).exists()) + + def test_sibling_of(self): + events_index = Page.objects.get(url_path='/home/events/') + event = Page.objects.get(url_path='/home/events/christmas/') + pages = Page.objects.sibling_of(event) + + # Check that all pages are children of events_index + for page in pages: + self.assertEqual(page.get_parent(), events_index) + + # Check that the event is not included + self.assertFalse(pages.filter(id=event.id).exists()) + + def test_sibling_of_inclusive(self): + events_index = Page.objects.get(url_path='/home/events/') + event = Page.objects.get(url_path='/home/events/christmas/') + pages = Page.objects.sibling_of(event, inclusive=True) + + # Check that all pages are children of events_index + for page in pages: + self.assertEqual(page.get_parent(), events_index) + + # Check that the event is included + self.assertTrue(pages.filter(id=event.id).exists()) + + def test_not_sibling_of(self): + events_index = Page.objects.get(url_path='/home/events/') + event = Page.objects.get(url_path='/home/events/christmas/') + pages = Page.objects.not_sibling_of(event) + + # Check that all pages are not children of events_index + for page in pages: + if page != event: + self.assertNotEqual(page.get_parent(), events_index) + + # Check that the event is included + self.assertTrue(pages.filter(id=event.id).exists()) + + # Test that events index is in pages + self.assertTrue(pages.filter(id=events_index.id).exists()) + + def test_not_sibling_of_inclusive(self): + events_index = Page.objects.get(url_path='/home/events/') + event = Page.objects.get(url_path='/home/events/christmas/') + pages = Page.objects.not_sibling_of(event, inclusive=True) + + # Check that all pages are not children of events_index + for page in pages: + self.assertNotEqual(page.get_parent(), events_index) + + # Check that the event is not included + self.assertFalse(pages.filter(id=event.id).exists()) + + # Test that events index is in pages + self.assertTrue(pages.filter(id=events_index.id).exists()) + + def test_type(self): + pages = Page.objects.type(EventPage) + + # Check that all objects are EventPages + for page in pages: + self.assertIsInstance(page.specific, EventPage) + + # Check that "someone elses event" is in the results + event = Page.objects.get(url_path='/home/events/someone-elses-event/') + self.assertTrue(pages.filter(id=event.id).exists()) + + def test_not_type(self): + pages = Page.objects.not_type(EventPage) + + # Check that no objects are EventPages + for page in pages: + self.assertNotIsInstance(page.specific, EventPage) + + # Check that the homepage is in the results + homepage = Page.objects.get(url_path='/home/') + self.assertTrue(pages.filter(id=homepage.id).exists())