diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 3f64dbc7f..d5ade9a8b 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -5,6 +5,7 @@ Changelog ~~~~~~~~~~~~~~~~ * Add `HelpPanel` to add HTML within an edit form (Keving Chung) + * Added API endpoint for finding pages by HTML path (Karl Hobley) * Persist tab hash in URL to allow direct navigation to tabs in the admin interface (Ben Weatherman) * Animate the chevron icon when opening sub-menus in the admin (Carlo Ascani) * Look through the target link and target page slug (in addition to the old slug) when searching for redirects in the admin (Michael Harrison) diff --git a/docs/advanced_topics/api/v2/usage.rst b/docs/advanced_topics/api/v2/usage.rst index a338e46cb..69fc36f94 100644 --- a/docs/advanced_topics/api/v2/usage.rst +++ b/docs/advanced_topics/api/v2/usage.rst @@ -422,6 +422,18 @@ All exported fields will be returned in the response by default. You can use the For example: ``/api/v2/pages/1/?fields=_,title,body`` will return just the ``title`` and ``body`` of the page with the id of 1. + +.. _apiv2_finding_pages_by_path: + +Finding pages by HTML path +-------------------------- + +You can find an individual page by its HTML path using the ``/api/v2/pages/find/?html_path=`` view. + +This will return either a ``302`` redirect response to that page's detail view, or a ``404`` not found response. + +For example: ``/api/v2/pages/find/?html_path=/`` always redirects to the homepage of the site + Default endpoint fields ======================= diff --git a/docs/releases/2.1.rst b/docs/releases/2.1.rst index 9508c19ff..c0cf150fb 100644 --- a/docs/releases/2.1.rst +++ b/docs/releases/2.1.rst @@ -14,7 +14,13 @@ New ``HelpPanel`` ~~~~~~~~~~~~~~~~~ A new panel type ``HelpPanel`` allows to easily add HTML within an edit form. -This new feature was developed by Keving Chung. +This new feature was developed by Kevin Chung. + + +API lookup by page path +~~~~~~~~~~~~~~~~~~~~~~~ + +The API now includes an endpoint for finding pages by path; see :ref:`apiv2_finding_pages_by_path`. This feature was developed by Karl Hobley. Other features ~~~~~~~~~~~~~~ diff --git a/wagtail/api/v2/endpoints.py b/wagtail/api/v2/endpoints.py index a4453bd39..6b5bd7a03 100644 --- a/wagtail/api/v2/endpoints.py +++ b/wagtail/api/v2/endpoints.py @@ -3,6 +3,7 @@ from collections import OrderedDict from django.conf.urls import url from django.core.exceptions import FieldDoesNotExist from django.http import Http404 +from django.shortcuts import redirect from django.urls import reverse from modelcluster.fields import ParentalKey from rest_framework import status @@ -19,7 +20,7 @@ from .filters import ( from .pagination import WagtailPagination from .serializers import BaseSerializer, PageSerializer, get_serializer_class from .utils import ( - BadRequestError, filter_page_type, page_models_from_string, parse_fields_parameter) + BadRequestError, filter_page_type, page_models_from_string, parse_fields_parameter, get_object_detail_url) class BaseAPIEndpoint(GenericViewSet): @@ -76,6 +77,34 @@ class BaseAPIEndpoint(GenericViewSet): serializer = self.get_serializer(instance) return Response(serializer.data) + def find_view(self, request): + queryset = self.get_queryset() + + try: + obj = self.find_object(queryset, request) + + if obj is None: + raise self.model.DoesNotExist + + except self.model.DoesNotExist: + raise Http404("not found") + + # Generate redirect + url = get_object_detail_url(self.request.wagtailapi_router, request, self.model, obj.pk) + + if url is None: + # Shouldn't happen unless this endpoint isn't actually installed in the router + raise Exception("Cannot generate URL to detail view. Is '{}' installed in the API router?".format(self.__class__.__name__)) + + return redirect(url) + + def find_object(self, queryset, request): + """ + Override this to implement more find methods. + """ + if 'id' in request.GET: + return queryset.get(id=request.GET['id']) + def handle_exception(self, exc): if isinstance(exc, Http404): data = {'message': str(exc)} @@ -310,6 +339,7 @@ class BaseAPIEndpoint(GenericViewSet): return [ url(r'^$', cls.as_view({'get': 'listing_view'}), name='listing'), url(r'^(?P\d+)/$', cls.as_view({'get': 'detail_view'}), name='detail'), + url(r'^find/$', cls.as_view({'get': 'find_view'}), name='find'), ] @classmethod @@ -405,3 +435,18 @@ class PagesAPIEndpoint(BaseAPIEndpoint): def get_object(self): base = super().get_object() return base.specific + + def find_object(self, queryset, request): + if 'html_path' in request.GET and request.site is not None: + path = request.GET['html_path'] + path_components = [component for component in path.split('/') if component] + + try: + page, _, _ = request.site.root_page.specific.route(request, path_components) + except Http404: + return + + if queryset.filter(id=page.id).exists(): + return page + + return super().find_object(queryset, request) diff --git a/wagtail/api/v2/serializers.py b/wagtail/api/v2/serializers.py index 1efbef369..08425e61b 100644 --- a/wagtail/api/v2/serializers.py +++ b/wagtail/api/v2/serializers.py @@ -8,14 +8,7 @@ from taggit.managers import _TaggableManager from wagtail.core import fields as wagtailcore_fields -from .utils import get_full_url, pages_for_site - - -def get_object_detail_url(context, model, pk): - url_path = context['router'].get_object_detail_urlpath(model, pk) - - if url_path: - return get_full_url(context['request'], url_path) +from .utils import get_object_detail_url, pages_for_site class TypeField(Field): @@ -42,7 +35,7 @@ class DetailUrlField(Field): "detail_url": "http://api.example.com/v1/images/1/" """ def get_attribute(self, instance): - url = get_object_detail_url(self.context, type(instance), instance.pk) + url = get_object_detail_url(self.context['router'], self.context['request'], type(instance), instance.pk) if url: return url diff --git a/wagtail/api/v2/tests/test_documents.py b/wagtail/api/v2/tests/test_documents.py index 368878cad..53a29a32b 100644 --- a/wagtail/api/v2/tests/test_documents.py +++ b/wagtail/api/v2/tests/test_documents.py @@ -485,6 +485,44 @@ class TestDocumentDetail(TestCase): self.assertEqual(content, {'message': "'title' does not support nested fields"}) +class TestDocumentFind(TestCase): + fixtures = ['demosite.json'] + + def get_response(self, **params): + return self.client.get(reverse('wagtailapi_v2:documents:find'), params) + + def test_without_parameters(self): + response = self.get_response() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + def test_find_by_id(self): + response = self.get_response(id=5) + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:documents:detail', args=[5]), fetch_redirect_response=False) + + def test_find_by_id_nonexistent(self): + response = self.get_response(id=1234) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + @override_settings( WAGTAILFRONTENDCACHE={ 'varnish': { diff --git a/wagtail/api/v2/tests/test_images.py b/wagtail/api/v2/tests/test_images.py index 5d3911e29..840c22958 100644 --- a/wagtail/api/v2/tests/test_images.py +++ b/wagtail/api/v2/tests/test_images.py @@ -479,6 +479,44 @@ class TestImageDetail(TestCase): self.assertEqual(content, {'message': "'title' does not support nested fields"}) +class TestImageFind(TestCase): + fixtures = ['demosite.json'] + + def get_response(self, **params): + return self.client.get(reverse('wagtailapi_v2:images:find'), params) + + def test_without_parameters(self): + response = self.get_response() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + def test_find_by_id(self): + response = self.get_response(id=5) + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:images:detail', args=[5]), fetch_redirect_response=False) + + def test_find_by_id_nonexistent(self): + response = self.get_response(id=1234) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + @override_settings( WAGTAILFRONTENDCACHE={ 'varnish': { diff --git a/wagtail/api/v2/tests/test_pages.py b/wagtail/api/v2/tests/test_pages.py index f714e52d7..b76e62a13 100644 --- a/wagtail/api/v2/tests/test_pages.py +++ b/wagtail/api/v2/tests/test_pages.py @@ -1038,6 +1038,67 @@ class TestPageDetail(TestCase): self.assertEqual(content, {'message': "'title' does not support nested fields"}) +class TestPageFind(TestCase): + fixtures = ['demosite.json'] + + def get_response(self, **params): + return self.client.get(reverse('wagtailapi_v2:pages:find'), params) + + def test_without_parameters(self): + response = self.get_response() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + def test_find_by_id(self): + response = self.get_response(id=5) + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:pages:detail', args=[5]), fetch_redirect_response=False) + + def test_find_by_id_nonexistent(self): + response = self.get_response(id=1234) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + def test_find_by_html_path(self): + response = self.get_response(html_path='/events-index/event-1/') + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:pages:detail', args=[8]), fetch_redirect_response=False) + + def test_find_by_html_path_with_start_and_end_slashes_removed(self): + response = self.get_response(html_path='events-index/event-1') + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:pages:detail', args=[8]), fetch_redirect_response=False) + + def test_find_by_html_path_nonexistent(self): + response = self.get_response(html_path='/foo') + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + class TestPageDetailWithStreamField(TestCase): fixtures = ['test.json'] diff --git a/wagtail/api/v2/utils.py b/wagtail/api/v2/utils.py index ba0c48b6e..84bcee8a0 100644 --- a/wagtail/api/v2/utils.py +++ b/wagtail/api/v2/utils.py @@ -25,6 +25,13 @@ def get_full_url(request, path): return base_url + path +def get_object_detail_url(router, request, model, pk): + url_path = router.get_object_detail_urlpath(model, pk) + + if url_path: + return get_full_url(request, url_path) + + def pages_for_site(site): pages = Page.objects.public().live() pages = pages.descendant_of(site.root_page, inclusive=True)