Repository: wagtail/wagtail

Issue:
Add ancestor_of filter to Wagtail pages API
We're using Wagtail as an headless CMS for a Vue/Nuxt site and recently had the need to find ancestors of a page to show a breadcrumb trail. Currently the API has a descendent_of filter, but not the other way around.

We've implemented a filter (heavily inspired by Wagtail's existing `DescendantOfFilter`) and added it to the `PagesAPIViewSet`, but it seems like a nice addition to API in general.

We've also found another use case for this filter. When a site has multiple index pages for a specific page type the `ancestor_of` filter can be combined with the `type` filter to find a specific type of ancestor for the current page.

Would this be accepted in Wagtail?

```
class AncestorOfFilter(BaseFilterBackend):
    """
    Implements the ?ancestor filter which limits the set of pages to a
    particular branch of the page tree.
    """
    def filter_queryset(self, request, queryset, view):
        if 'ancestor_of' in request.GET:
            try:
                descendant_page_id = int(request.GET['ancestor_of'])
                if descendant_page_id < 0:
                    raise ValueError()

                descendant_page = view.get_base_queryset().get(id=descendant_page_id)
            except ValueError:
                raise BadRequestError("ancestor_of must be a positive integer")
            except Page.DoesNotExist:
                raise BadRequestError("descendant page doesn't exist")

            queryset = queryset.ancestor_of(descendant_page)

        return queryset
```



Discussion:
Thanks @jaap3, yes a pull request for this would be very welcome!
I'll try to find some time soon to work on a PR with tests and docs

Resolving patch:
diff --git a/wagtail/api/v2/filters.py b/wagtail/api/v2/filters.py
--- a/wagtail/api/v2/filters.py
+++ b/wagtail/api/v2/filters.py
@@ -165,6 +165,29 @@ def filter_queryset(self, request, queryset, view):
         return queryset
 
 
+class AncestorOfFilter(BaseFilterBackend):
+    """
+    Implements the ?ancestor filter which limits the set of pages to a
+    particular branch of the page tree.
+    """
+    def filter_queryset(self, request, queryset, view):
+        if 'ancestor_of' in request.GET:
+            try:
+                descendant_page_id = int(request.GET['ancestor_of'])
+                if descendant_page_id < 0:
+                    raise ValueError()
+
+                descendant_page = view.get_base_queryset().get(id=descendant_page_id)
+            except ValueError:
+                raise BadRequestError("ancestor_of must be a positive integer")
+            except Page.DoesNotExist:
+                raise BadRequestError("descendant page doesn't exist")
+
+            queryset = queryset.ancestor_of(descendant_page)
+
+        return queryset
+
+
 class DescendantOfFilter(BaseFilterBackend):
     """
     Implements the ?decendant_of filter which limits the set of pages to a
diff --git a/wagtail/api/v2/views.py b/wagtail/api/v2/views.py
--- a/wagtail/api/v2/views.py
+++ b/wagtail/api/v2/views.py
@@ -15,8 +15,8 @@
 from wagtail.core.models import Page, Site
 
 from .filters import (
-    ChildOfFilter, DescendantOfFilter, FieldsFilter, LocaleFilter, OrderingFilter, SearchFilter,
-    TranslationOfFilter)
+    AncestorOfFilter, ChildOfFilter, DescendantOfFilter, FieldsFilter, LocaleFilter, OrderingFilter,
+    SearchFilter, TranslationOfFilter)
 from .pagination import WagtailPagination
 from .serializers import BaseSerializer, PageSerializer, get_serializer_class
 from .utils import (
@@ -366,6 +366,7 @@ class PagesAPIViewSet(BaseAPIViewSet):
     filter_backends = [
         FieldsFilter,
         ChildOfFilter,
+        AncestorOfFilter,
         DescendantOfFilter,
         OrderingFilter,
         TranslationOfFilter,
@@ -375,6 +376,7 @@ class PagesAPIViewSet(BaseAPIViewSet):
     known_query_parameters = BaseAPIViewSet.known_query_parameters.union([
         'type',
         'child_of',
+        'ancestor_of',
         'descendant_of',
         'translation_of',
         'locale',


Test patch:
diff --git a/wagtail/api/v2/tests/test_pages.py b/wagtail/api/v2/tests/test_pages.py
--- a/wagtail/api/v2/tests/test_pages.py
+++ b/wagtail/api/v2/tests/test_pages.py
@@ -563,6 +563,44 @@ def test_child_of_page_thats_not_in_same_site_gives_error(self):
         self.assertEqual(response.status_code, 400)
         self.assertEqual(content, {'message': "parent page doesn't exist"})
 
+    # ANCESTOR OF FILTER
+
+    def test_ancestor_of_filter(self):
+        response = self.get_response(ancestor_of=10)
+        content = response.json()
+
+        page_id_list = self.get_page_id_list(content)
+        self.assertEqual(page_id_list, [2, 6])
+
+    def test_ancestor_of_with_type(self):
+        response = self.get_response(type='demosite.eventindexpage', ancestor_of=8)
+        content = response.json()
+
+        page_id_list = self.get_page_id_list(content)
+        self.assertEqual(page_id_list, [4])
+
+    def test_ancestor_of_unknown_page_gives_error(self):
+        response = self.get_response(ancestor_of=1000)
+        content = response.json()
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(content, {'message': "descendant page doesn't exist"})
+
+    def test_ancestor_of_not_integer_gives_error(self):
+        response = self.get_response(ancestor_of='abc')
+        content = response.json()
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(content, {'message': "ancestor_of must be a positive integer"})
+
+    def test_ancestor_of_home_page_ignores_root(self):
+        # Root page is not in any site, so pretend it doesn't exist
+        response = self.get_response(ancestor_of=2)
+        content = response.json()
+
+        page_id_list = self.get_page_id_list(content)
+        self.assertEqual(page_id_list, [])
+
     # DESCENDANT OF FILTER
 
     def test_descendant_of_filter(self):
@@ -582,11 +620,11 @@ def test_descendant_of_root(self):
         self.assertEqual(page_id_list, [4, 8, 9, 5, 16, 18, 19, 6, 10, 15, 17, 21, 22, 23, 20, 13, 14, 12])
 
     def test_descendant_of_with_type(self):
-        response = self.get_response(type='tests.EventPage', descendant_of=6)
+        response = self.get_response(type='demosite.eventindexpage', descendant_of=2)
         content = json.loads(response.content.decode('UTF-8'))
 
         page_id_list = self.get_page_id_list(content)
-        self.assertEqual(page_id_list, [])
+        self.assertEqual(page_id_list, [4])
 
     def test_descendant_of_unknown_page_gives_error(self):
         response = self.get_response(descendant_of=1000)