Use short "Go" view for search shortcut

This commit is contained in:
Jake Howard 2024-01-12 15:16:31 +00:00
parent e9f74ec0c1
commit 59912f6ddb
Signed by: jake
GPG key ID: 57AFB45680EDD477
6 changed files with 124 additions and 21 deletions

View file

@ -5,6 +5,7 @@ from typing import Iterable, Optional, Type
import requests import requests
from bs4 import BeautifulSoup, SoupStrainer from bs4 import BeautifulSoup, SoupStrainer
from django.conf import settings from django.conf import settings
from django.db import models
from django.http.request import HttpRequest from django.http.request import HttpRequest
from django.utils.text import re_words, slugify from django.utils.text import re_words, slugify
from django_cache_decorator import django_cache_decorator from django_cache_decorator import django_cache_decorator
@ -118,3 +119,13 @@ def get_url_mime_type(url: str) -> Optional[str]:
return requests_session.head(url).headers.get("Content-Type") return requests_session.head(url).headers.get("Content-Type")
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
return None return None
def get_or_none(queryset: models.QuerySet) -> models.Model:
"""
Helper method to get a single instance, or None if there is not exactly 1 matches
"""
try:
return queryset.get()
except (queryset.model.DoesNotExist, queryset.model.MultipleObjectsReturned):
return None

View file

@ -13,7 +13,7 @@ from wagtail.search.utils import parse_query_string
from website.common.models import BaseContentPage, BaseListingPage from website.common.models import BaseContentPage, BaseListingPage
from website.common.utils import get_page_models from website.common.utils import get_page_models
from .serializers import MIN_SEARCH_LENGTH, SearchParamsSerializer from .serializers import MIN_SEARCH_LENGTH, SearchPageParamsSerializer
class SearchPage(RoutablePageMixin, BaseContentPage): class SearchPage(RoutablePageMixin, BaseContentPage):
@ -44,11 +44,12 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
context["SEO_INDEX"] = False context["SEO_INDEX"] = False
return context return context
def get_listing_pages(self) -> models.QuerySet: @classmethod
def get_listing_pages(cls) -> models.QuerySet:
return ( return (
Page.objects.live() Page.objects.live()
.public() .public()
.not_type(self.__class__, *self.EXCLUDED_PAGE_TYPES) .not_type(cls.__class__, *cls.EXCLUDED_PAGE_TYPES)
) )
@route(r"^results/$") @route(r"^results/$")
@ -57,7 +58,7 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
if not request.htmx: if not request.htmx:
return HttpResponseBadRequest() return HttpResponseBadRequest()
serializer = SearchParamsSerializer(data=request.GET) serializer = SearchPageParamsSerializer(data=request.GET)
if not serializer.is_valid(): if not serializer.is_valid():
return TemplateResponse( return TemplateResponse(

View file

@ -5,5 +5,9 @@ from website.common.serializers import PaginationSerializer
MIN_SEARCH_LENGTH = 3 MIN_SEARCH_LENGTH = 3
class SearchParamsSerializer(PaginationSerializer): class SearchParamSerializer(serializers.Serializer):
q = serializers.CharField(min_length=MIN_SEARCH_LENGTH) q = serializers.CharField(min_length=MIN_SEARCH_LENGTH)
class SearchPageParamsSerializer(SearchParamSerializer, PaginationSerializer):
pass

View file

@ -140,18 +140,85 @@ class OpenSearchTestCase(TestCase):
ContentPageFactory(parent=cls.home_page, title=f"Post {i}") ContentPageFactory(parent=cls.home_page, title=f"Post {i}")
def test_opensearch_description(self) -> None: def test_opensearch_description(self) -> None:
with self.assertNumQueries(8): with self.assertNumQueries(6):
response = self.client.get(reverse("opensearch")) response = self.client.get(reverse("opensearch"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, self.page.get_url()) self.assertContains(response, reverse("go"))
self.assertContains(response, reverse("opensearch-suggestions")) self.assertContains(response, reverse("opensearch-suggestions"))
def test_opensearch_suggestions(self) -> None: def test_opensearch_suggestions(self) -> None:
with self.assertNumQueries(4): with self.assertNumQueries(3):
response = self.client.get(reverse("opensearch-suggestions"), {"q": "post"}) response = self.client.get(reverse("opensearch-suggestions"), {"q": "post"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
self.assertEqual(data[0], "post") self.assertEqual(data[0], "post")
self.assertEqual(data[1], [f"Post {i}" for i in range(5)]) self.assertEqual(data[1], [f"Post {i}" for i in range(5)])
class GoViewTestCase(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.home_page = HomePage.objects.get()
cls.search_page = SearchPageFactory(parent=cls.home_page)
cls.post_1 = ContentPageFactory(
parent=cls.home_page, title="Post Title 1", slug="post-slug-1"
)
cls.post_2 = ContentPageFactory(
parent=cls.home_page, title="Post Title 2", slug="post-slug-2"
)
def test_by_title(self) -> None:
with self.assertNumQueries(5):
response = self.client.get(reverse("go"), {"q": self.post_1.title})
self.assertRedirects(
response, self.post_1.get_url(), fetch_redirect_response=True
)
def test_by_slug(self) -> None:
with self.assertNumQueries(7):
response = self.client.get(reverse("go"), {"q": self.post_2.slug})
self.assertRedirects(
response, self.post_2.get_url(), fetch_redirect_response=True
)
def test_no_match(self) -> None:
with self.assertNumQueries(6):
response = self.client.get(reverse("go"), {"q": "Something else"})
self.assertRedirects(
response,
self.search_page.get_url() + "?q=Something+else",
fetch_redirect_response=True,
)
def test_no_query(self) -> None:
with self.assertNumQueries(3):
response = self.client.get(reverse("go"))
self.assertRedirects(
response, self.search_page.get_url(), fetch_redirect_response=True
)
def test_multiple_matches(self) -> None:
ContentPageFactory(parent=self.home_page, title=self.post_1.title)
with self.assertNumQueries(6):
response = self.client.get(reverse("go"), {"q": self.post_1.title})
self.assertRedirects(
response,
self.search_page.get_url() + f"?q={self.post_1.title}",
fetch_redirect_response=True,
)
def test_no_search_page(self) -> None:
self.search_page.delete()
response = self.client.get(reverse("go"))
self.assertEqual(response.status_code, 404)

View file

@ -9,4 +9,5 @@ urlpatterns = [
views.OpenSearchSuggestionsView.as_view(), views.OpenSearchSuggestionsView.as_view(),
name="opensearch-suggestions", name="opensearch-suggestions",
), ),
path("go/", views.GoView.as_view(), name="go"),
] ]

View file

@ -1,18 +1,17 @@
from django.http import HttpRequest, JsonResponse from django.http import Http404, HttpRequest, JsonResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control from django.views.decorators.cache import cache_control, cache_page
from django.views.generic import TemplateView, View from django.views.generic import RedirectView, TemplateView, View
from wagtail.search.utils import parse_query_string from wagtail.search.utils import parse_query_string
from wagtail_favicon.models import FaviconSettings from wagtail_favicon.models import FaviconSettings
from wagtail_favicon.utils import get_rendition_url from wagtail_favicon.utils import get_rendition_url
from website.common.utils import get_site_title from website.common.utils import get_or_none, get_site_title
from website.contrib.singleton_page.utils import SingletonPageCache from website.contrib.singleton_page.utils import SingletonPageCache
from .models import SearchPage from .models import SearchPage
from .serializers import SearchParamsSerializer from .serializers import SearchParamSerializer
@method_decorator(cache_control(max_age=60 * 60), name="dispatch") @method_decorator(cache_control(max_age=60 * 60), name="dispatch")
@ -32,9 +31,7 @@ class OpenSearchView(TemplateView):
) )
) )
context["search_page_url"] = self.request.build_absolute_uri( context["search_page_url"] = self.request.build_absolute_uri(reverse("go"))
SingletonPageCache.get_url(SearchPage, self.request)
)
context["search_suggestions_url"] = self.request.build_absolute_uri( context["search_suggestions_url"] = self.request.build_absolute_uri(
reverse("opensearch-suggestions") reverse("opensearch-suggestions")
) )
@ -47,17 +44,15 @@ class OpenSearchView(TemplateView):
@method_decorator(cache_control(max_age=60 * 60), name="dispatch") @method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class OpenSearchSuggestionsView(View): class OpenSearchSuggestionsView(View):
def get(self, request: HttpRequest) -> JsonResponse: def get(self, request: HttpRequest) -> JsonResponse:
serializer = SearchParamsSerializer(data=request.GET) serializer = SearchParamSerializer(data=request.GET)
if not serializer.is_valid(): if not serializer.is_valid():
return JsonResponse(serializer.errors, status=400) return JsonResponse(serializer.errors, status=400)
search_page = get_object_or_404(SearchPage)
filters, query = parse_query_string(serializer.validated_data["q"]) filters, query = parse_query_string(serializer.validated_data["q"])
results = ( results = (
search_page.get_listing_pages() SearchPage.get_listing_pages()
.search(query, order_by_relevance=True)[:5] .search(query, order_by_relevance=True)[:5]
.get_queryset() .get_queryset()
) )
@ -69,3 +64,27 @@ class OpenSearchSuggestionsView(View):
], ],
safe=False, safe=False,
) )
@method_decorator(cache_page(60 * 60), name="dispatch")
class GoView(RedirectView):
def get_redirect_url(self) -> str:
serializer = SearchParamSerializer(data=self.request.GET)
search_page_url = SingletonPageCache.get_url(SearchPage, self.request)
if search_page_url is None:
raise Http404
if not serializer.is_valid():
return search_page_url
query = serializer.validated_data["q"]
pages = SearchPage.get_listing_pages()
if title_match := get_or_none(pages.filter(title__iexact=query)):
return title_match.get_url(request=self.request)
if slug_match := get_or_none(pages.filter(slug__iexact=query)):
return slug_match.get_url(request=self.request)
return f"{search_page_url}?{self.request.GET.urlencode()}"