diff --git a/apps/core/models/content.py b/apps/core/models/content.py index 98720ac0..731d6442 100644 --- a/apps/core/models/content.py +++ b/apps/core/models/content.py @@ -2,14 +2,15 @@ from bs4 import BeautifulSoup from django.http import HttpResponse -from django.template import Context, Template from django.utils.functional import cached_property from django.utils.html import format_html from django.utils.text import slugify from wagtail.admin.panels import FieldPanel from wagtail.api import APIField +from wagtail.blocks import StructValue from wagtail.fields import StreamField from wagtail.models import Page +from wagtail.rich_text import RichText from wagtail.search import index from wagtail_ai.panels import AITitleFieldPanel @@ -19,10 +20,22 @@ from ..blocks import CONTENT_BLOCKS +def get_rich_text_sources(body): + for block in body: + if isinstance(block.value, StructValue): + values = block.value.values() + else: + values = [block.value] + for value in values: + if isinstance(value, RichText): + yield value.source + + def create_table_of_contents(body): - template = Template("{% load wagtailcore_tags %}{% include_block body %}") - content = template.render(Context({"body": body})) - soup = BeautifulSoup(content, "html.parser") + # Headings and their ids are stored in the rich text HTML, so read them + # from there rather than rendering the body a second time, which would + # repeat the database lookups for every link in it. + soup = BeautifulSoup("".join(get_rich_text_sources(body)), "html.parser") headings = soup.select("h2,h3") toc = "" if headings: diff --git a/apps/core/tests/test_content_page.py b/apps/core/tests/test_content_page.py index 1fd1aec5..fa06c172 100644 --- a/apps/core/tests/test_content_page.py +++ b/apps/core/tests/test_content_page.py @@ -66,6 +66,45 @@ def test_escapes_existing_id(self): '
', ) + def test_create_table_of_contents_annotated_text(self): + self.content_page.body = json.dumps( + [ + { + "type": "text_annotated", + "value": { + "content": 'Content
", change_type="added") response = self.client.get(page.url) - self.assertTemplateUsed(response, "core/blocks/text_annotated.html", count=2) + self.assertTemplateUsed(response, "core/blocks/text_annotated.html", count=1) def test_version_validation_accepts_x_y_format(self): version_block = AnnotatedTextBlock().child_blocks["version"] diff --git a/apps/search/tests/__init__.py b/apps/search/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/search/tests/test_views.py b/apps/search/tests/test_views.py new file mode 100644 index 00000000..e283f3e2 --- /dev/null +++ b/apps/search/tests/test_views.py @@ -0,0 +1,64 @@ +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext + +from apps.core.factories import ContentPageFactory, HomePageFactory + +SEARCH_JSON_URL = "/en/search_json/" + + +class TestSearchJson(TestCase): + def setUp(self): + self.home = HomePageFactory() + self.section = ContentPageFactory(parent=self.home, title="Section") + + def search(self, query): + response = self.client.get(SEARCH_JSON_URL, {"query": query}) + self.assertEqual(response.status_code, 200) + return response.json() + + def get_query_count(self, query): + with CaptureQueriesContext(connection) as ctx: + self.search(query) + return len(ctx.captured_queries) + + def test_no_query(self): + response = self.client.get(SEARCH_JSON_URL) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_parent_section(self): + page = ContentPageFactory(parent=self.section, title="Zebra page") + ContentPageFactory(parent=page, title="Zebra subpage") + ContentPageFactory(parent=self.home, title="Zebra section") + + results = self.search("zebra") + + self.assertEqual( + sorted((result["title"], result["parent_section"]) for result in results), + [ + ("Zebra page", "Section"), + ("Zebra section", "Home"), + ("Zebra subpage", "Section"), + ], + ) + + def test_full_url(self): + page = ContentPageFactory(parent=self.section, title="Zebra page") + + results = self.search("zebra") + + self.assertEqual([result["full_url"] for result in results], [page.full_url]) + + def test_results_do_not_add_queries(self): + ContentPageFactory(parent=self.section, title="Zebra 0") + self.search("zebra") + expected = self.get_query_count("zebra") + + other_section = ContentPageFactory(parent=self.home, title="Other") + for i in range(1, 5): + ContentPageFactory(parent=other_section, title=f"Zebra {i}") + + self.assertEqual(len(self.search("zebra")), 5) + self.assertEqual(self.get_query_count("zebra"), expected) diff --git a/apps/search/views.py b/apps/search/views.py index 1ff09c0b..f1dce7ac 100644 --- a/apps/search/views.py +++ b/apps/search/views.py @@ -47,17 +47,36 @@ def search(request): ) +# Sections are the children of each locale's homepage, under the tree root. +SECTION_DEPTH = 3 + + +def get_section_path(page): + return page.path[: Page.steplen * SECTION_DEPTH] + + +def get_section_titles(pages): + """Titles of the sections the pages are in, keyed by section path.""" + paths = {get_section_path(page) for page in pages if page.depth > SECTION_DEPTH} + return dict(Page.objects.filter(path__in=paths).values_list("path", "title")) + + class PageSerializer(serializers.ModelSerializer): + full_url = serializers.SerializerMethodField("get_full_url") parent_section = serializers.SerializerMethodField("get_parent_section") class Meta: model = Page fields = ["id", "title", "search_description", "full_url", "parent_section"] + def get_full_url(self, page): + # With the request, Wagtail looks up the site root paths once for all + # results, rather than once per result. + return page.get_full_url(self.context["request"]) + def get_parent_section(self, page): - ancestors = page.get_ancestors() - if len(ancestors) >= 3: - return ancestors[2].title + if page.depth > SECTION_DEPTH: + return self.context["section_titles"][get_section_path(page)] else: return _("Home") @@ -77,5 +96,14 @@ def search_json(request): query.add_hit() else: search_results = Page.objects.none() - serializer = PageSerializer(search_results, many=True) + # Look up all the parent sections at once, rather than once per result. + search_results = list(search_results) + serializer = PageSerializer( + search_results, + many=True, + context={ + "request": request, + "section_titles": get_section_titles(search_results), + }, + ) return Response(serializer.data)