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
from bs4 import BeautifulSoup, SoupStrainer
from django.conf import settings
from django.db import models
from django.http.request import HttpRequest
from django.utils.text import re_words, slugify
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")
except requests.exceptions.RequestException:
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.utils import get_page_models
from .serializers import MIN_SEARCH_LENGTH, SearchParamsSerializer
from .serializers import MIN_SEARCH_LENGTH, SearchPageParamsSerializer
class SearchPage(RoutablePageMixin, BaseContentPage):
@ -44,11 +44,12 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
context["SEO_INDEX"] = False
return context
def get_listing_pages(self) -> models.QuerySet:
@classmethod
def get_listing_pages(cls) -> models.QuerySet:
return (
Page.objects.live()
.public()
.not_type(self.__class__, *self.EXCLUDED_PAGE_TYPES)
.not_type(cls.__class__, *cls.EXCLUDED_PAGE_TYPES)
)
@route(r"^results/$")
@ -57,7 +58,7 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
if not request.htmx:
return HttpResponseBadRequest()
serializer = SearchParamsSerializer(data=request.GET)
serializer = SearchPageParamsSerializer(data=request.GET)
if not serializer.is_valid():
return TemplateResponse(

View File

@ -5,5 +5,9 @@ from website.common.serializers import PaginationSerializer
MIN_SEARCH_LENGTH = 3
class SearchParamsSerializer(PaginationSerializer):
class SearchParamSerializer(serializers.Serializer):
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}")
def test_opensearch_description(self) -> None:
with self.assertNumQueries(8):
with self.assertNumQueries(6):
response = self.client.get(reverse("opensearch"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.page.get_url())
self.assertContains(response, reverse("go"))
self.assertContains(response, reverse("opensearch-suggestions"))
def test_opensearch_suggestions(self) -> None:
with self.assertNumQueries(4):
with self.assertNumQueries(3):
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)])
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(),
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.shortcuts import get_object_or_404
from django.http import Http404, HttpRequest, JsonResponse
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 django.views.decorators.cache import cache_control, cache_page
from django.views.generic import RedirectView, 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.common.utils import get_or_none, get_site_title
from website.contrib.singleton_page.utils import SingletonPageCache
from .models import SearchPage
from .serializers import SearchParamsSerializer
from .serializers import SearchParamSerializer
@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(
SingletonPageCache.get_url(SearchPage, self.request)
)
context["search_page_url"] = self.request.build_absolute_uri(reverse("go"))
context["search_suggestions_url"] = self.request.build_absolute_uri(
reverse("opensearch-suggestions")
)
@ -47,17 +44,15 @@ class OpenSearchView(TemplateView):
@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
class OpenSearchSuggestionsView(View):
def get(self, request: HttpRequest) -> JsonResponse:
serializer = SearchParamsSerializer(data=request.GET)
serializer = SearchParamSerializer(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()
SearchPage.get_listing_pages()
.search(query, order_by_relevance=True)[:5]
.get_queryset()
)
@ -69,3 +64,27 @@ class OpenSearchSuggestionsView(View):
],
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()}"