From 5d50907ed235b4257c0b8c60db3738d85ce63e99 Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Thu, 4 Jan 2024 22:21:32 +0000 Subject: [PATCH] Add opensearch description file Not _that_ opensearch --- website/common/templates/base.html | 2 + website/search/models.py | 15 ++-- .../search/templates/search/opensearch.xml | 11 +++ website/search/tests.py | 28 ++++++++ website/search/urls.py | 12 ++++ website/search/views.py | 71 +++++++++++++++++++ website/urls.py | 1 + 7 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 website/search/templates/search/opensearch.xml create mode 100644 website/search/urls.py create mode 100644 website/search/views.py diff --git a/website/common/templates/base.html b/website/common/templates/base.html index 3adf201..90c9fc2 100644 --- a/website/common/templates/base.html +++ b/website/common/templates/base.html @@ -14,6 +14,8 @@ {% block extra_head %}{% endblock %} + + diff --git a/website/search/models.py b/website/search/models.py index b8c4488..c140d6b 100644 --- a/website/search/models.py +++ b/website/search/models.py @@ -1,4 +1,5 @@ from django.core.paginator import EmptyPage, Paginator +from django.db import models from django.http.request import HttpRequest from django.http.response import Http404, HttpResponse, HttpResponseBadRequest from django.template.response import TemplateResponse @@ -43,6 +44,13 @@ class SearchPage(RoutablePageMixin, BaseContentPage): context["SEO_INDEX"] = False return context + def get_listing_pages(self) -> models.QuerySet: + return ( + Page.objects.live() + .public() + .not_type(self.__class__, *self.EXCLUDED_PAGE_TYPES) + ) + @route(r"^results/$") @method_decorator(require_GET) def results(self, request: HttpRequest) -> HttpResponse: @@ -68,12 +76,7 @@ class SearchPage(RoutablePageMixin, BaseContentPage): } filters, query = parse_query_string(search_query) - pages = ( - Page.objects.live() - .public() - .not_type(self.__class__, *self.EXCLUDED_PAGE_TYPES) - .search(query, order_by_relevance=True) - ) + pages = self.get_listing_pages().search(query, order_by_relevance=True) paginator = Paginator(pages, self.PAGE_SIZE) context["paginator"] = paginator diff --git a/website/search/templates/search/opensearch.xml b/website/search/templates/search/opensearch.xml new file mode 100644 index 0000000..a1d98f0 --- /dev/null +++ b/website/search/templates/search/opensearch.xml @@ -0,0 +1,11 @@ + + + {{ site_title }} + {{ site_title }} + UTF-8 + {% if favicon_url %} + {{ favicon_url }} + {% endif %} + + + diff --git a/website/search/tests.py b/website/search/tests.py index bcdbfc1..58ea34f 100644 --- a/website/search/tests.py +++ b/website/search/tests.py @@ -1,5 +1,6 @@ from bs4 import BeautifulSoup from django.test import TestCase +from django.urls import reverse from website.common.factories import ContentPageFactory from website.home.models import HomePage @@ -127,3 +128,30 @@ class SearchPageResultsTestCase(TestCase): with self.assertNumQueries(7): response = self.client.get(self.url) self.assertEqual(response.status_code, 400) + + +class OpenSearchTestCase(TestCase): + @classmethod + def setUpTestData(cls) -> None: + cls.home_page = HomePage.objects.get() + cls.page = SearchPageFactory(parent=cls.home_page) + + for i in range(6): + ContentPageFactory(parent=cls.home_page, title=f"Post {i}") + + def test_opensearch_description(self) -> None: + with self.assertNumQueries(11): + response = self.client.get(reverse("opensearch")) + self.assertEqual(response.status_code, 200) + + self.assertContains(response, self.page.get_url()) + self.assertContains(response, reverse("opensearch-suggestions")) + + def test_opensearch_suggestions(self) -> None: + with self.assertNumQueries(4): + response = self.client.get(reverse("opensearch-suggestions"), {"q": "post"}) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertEqual(data[0], "post") + self.assertEqual(data[1], [f"Post {i}" for i in range(5)]) diff --git a/website/search/urls.py b/website/search/urls.py new file mode 100644 index 0000000..9b725c5 --- /dev/null +++ b/website/search/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("opensearch.xml", views.OpenSearchView.as_view(), name="opensearch"), + path( + "opensearch-suggestions/", + views.OpenSearchSuggestionsView.as_view(), + name="opensearch-suggestions", + ), +] diff --git a/website/search/views.py b/website/search/views.py new file mode 100644 index 0000000..5631b5b --- /dev/null +++ b/website/search/views.py @@ -0,0 +1,71 @@ +from django.http import HttpRequest, JsonResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_control +from django.views.generic import TemplateView, View +from wagtail.search.utils import parse_query_string +from wagtail_favicon.models import FaviconSettings +from wagtail_favicon.utils import get_rendition_url + +from website.common.utils import get_site_title +from website.contrib.singleton_page.utils import SingletonPageCache + +from .models import SearchPage +from .serializers import SearchParamsSerializer + + +@method_decorator(cache_control(max_age=60 * 60), name="dispatch") +class OpenSearchView(TemplateView): + template_name = "search/opensearch.xml" + content_type = "application/xml" + + def get_context_data(self, **kwargs: dict) -> dict: + context = super().get_context_data(**kwargs) + + favicon_settings = FaviconSettings.for_request(self.request) + + if favicon_settings.base_favicon_image_id: + context["favicon_url"] = self.request.build_absolute_uri( + get_rendition_url( + favicon_settings.base_favicon_image, "fill-100|format-png" + ) + ) + + context["search_page_url"] = self.request.build_absolute_uri( + SingletonPageCache.get_url(SearchPage, self.request) + ) + context["search_suggestions_url"] = self.request.build_absolute_uri( + reverse("opensearch-suggestions") + ) + + context["site_title"] = get_site_title() + + return context + + +@method_decorator(cache_control(max_age=60 * 60), name="dispatch") +class OpenSearchSuggestionsView(View): + def get(self, request: HttpRequest) -> JsonResponse: + serializer = SearchParamsSerializer(data=request.GET) + + if not serializer.is_valid(): + return JsonResponse(serializer.errors, status=400) + + search_page = get_object_or_404(SearchPage) + + filters, query = parse_query_string(serializer.validated_data["q"]) + + results = ( + search_page.get_listing_pages() + .search(query, order_by_relevance=True)[:5] + .get_queryset() + ) + + return JsonResponse( + [ + serializer.validated_data["q"], + list(results.values_list("title", flat=True)), + ], + safe=False, + ) diff --git a/website/urls.py b/website/urls.py index 63abd37..1614885 100644 --- a/website/urls.py +++ b/website/urls.py @@ -50,6 +50,7 @@ urlpatterns = [ path("feed/", AllPagesFeed(), name="feed"), path(".health/", include("health_check.urls")), path("", include("website.legacy.urls")), + path("", include("website.search.urls")), path("api/", include("website.api.urls", namespace="api")), path( "@jake",