diff --git a/apps/blog/models.py b/apps/blog/models.py index 91c729c..16928ec 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -235,8 +235,27 @@ class ArticlePage(SeoMixin, Page): search_fields = Page.search_fields + [ index.SearchField("summary"), + index.SearchField("body_text", es_extra={"analyzer": "english"}), + index.AutocompleteField("title"), + index.RelatedFields("tags", [ + index.SearchField("name"), + ]), + index.FilterField("category"), + index.FilterField("published_date"), ] + @property + def body_text(self) -> str: + """Extract prose text from body StreamField, excluding code blocks.""" + parts: list[str] = [] + for block in self.body: + if block.block_type == "code": + continue + value = block.value + text = value.source if hasattr(value, "source") else str(value) + parts.append(text) + return " ".join(parts) + def save(self, *args: Any, **kwargs: Any) -> None: if not self.category_id: self.category, _ = Category.objects.get_or_create( diff --git a/apps/blog/tests/test_search.py b/apps/blog/tests/test_search.py new file mode 100644 index 0000000..ef5ce18 --- /dev/null +++ b/apps/blog/tests/test_search.py @@ -0,0 +1,140 @@ +import pytest + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory +from apps.blog.views import MAX_QUERY_LENGTH + + +@pytest.fixture +def search_articles(home_page): + """Create an article index with searchable articles.""" + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + articles = [] + for title, summary in [ + ("Understanding LLM Benchmarks", "A deep dive into how language models are evaluated"), + ("Local Models on Apple Silicon", "Running open-source models on your MacBook"), + ("Agent Frameworks Compared", "Comparing LangChain, CrewAI, and AutoGen"), + ]: + a = ArticlePage( + title=title, + slug=title.lower().replace(" ", "-"), + author=author, + summary=summary, + body=[("rich_text", f"
{summary} in detail.
")], + ) + index.add_child(instance=a) + a.save_revision().publish() + articles.append(a) + return articles + + +@pytest.mark.django_db +class TestSearchView: + def test_empty_query_returns_no_results(self, client, home_page): + resp = client.get("/search/") + assert resp.status_code == 200 + assert resp.context["query"] == "" + assert resp.context["results"] is None + + def test_whitespace_query_returns_no_results(self, client, home_page): + resp = client.get("/search/?q= ") + assert resp.status_code == 200 + assert resp.context["query"] == "" + assert resp.context["results"] is None + + def test_search_returns_matching_articles(self, client, search_articles): + resp = client.get("/search/?q=benchmarks") + assert resp.status_code == 200 + assert resp.context["query"] == "benchmarks" + assert resp.context["results"] is not None + + def test_search_no_match_returns_empty_page(self, client, search_articles): + resp = client.get("/search/?q=zzzznonexistent") + assert resp.status_code == 200 + assert resp.context["query"] == "zzzznonexistent" + # Either None or empty page object + results = resp.context["results"] + if results is not None: + assert len(list(results)) == 0 + + def test_query_is_truncated_to_max_length(self, client, home_page): + long_query = "a" * 500 + resp = client.get(f"/search/?q={long_query}") + assert resp.status_code == 200 + assert len(resp.context["query"]) <= MAX_QUERY_LENGTH + + def test_query_preserved_in_template(self, client, search_articles): + resp = client.get("/search/?q=LLM") + html = resp.content.decode() + assert 'value="LLM"' in html + + def test_search_results_page_renders(self, client, search_articles): + resp = client.get("/search/?q=models") + assert resp.status_code == 200 + html = resp.content.decode() + assert "Search" in html + + def test_search_url_resolves(self, client, home_page): + from django.urls import reverse + assert reverse("search") == "/search/" + + +@pytest.mark.django_db +class TestSearchFields: + def test_search_fields_include_summary(self): + field_names = [ + f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name") + ] + assert "summary" in field_names + + def test_search_fields_include_body_text(self): + field_names = [ + f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name") + ] + assert "body_text" in field_names + + def test_search_fields_include_autocomplete_title(self): + from wagtail.search.index import AutocompleteField + autocomplete_fields = [ + f for f in ArticlePage.search_fields if isinstance(f, AutocompleteField) + ] + assert any(f.field_name == "title" for f in autocomplete_fields) + + def test_search_fields_include_related_tags(self): + from wagtail.search.index import RelatedFields + related = [f for f in ArticlePage.search_fields if isinstance(f, RelatedFields)] + assert any(f.field_name == "tags" for f in related) + + def test_body_text_excludes_code_blocks(self): + author = AuthorFactory() + article = ArticlePage( + title="Test", + slug="test", + author=author, + summary="summary", + body=[ + ("rich_text", "prose content here
"), + ("code", {"language": "python", "filename": "", "raw_code": "def secret(): pass"}), + ], + ) + assert "prose content here" in article.body_text + assert "secret" not in article.body_text + + +@pytest.mark.django_db +class TestSearchNavIntegration: + def test_nav_contains_search_form(self, client, home_page): + resp = client.get("/") + html = resp.content.decode() + assert 'role="search"' in html + assert 'name="q"' in html + assert 'placeholder="Search articles..."' in html + + def test_article_index_contains_search_form(self, client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + resp = client.get("/articles/") + html = resp.content.decode() + assert 'name="q"' in html diff --git a/apps/blog/tests/test_views.py b/apps/blog/tests/test_views.py index b3fe28d..2f07e5b 100644 --- a/apps/blog/tests/test_views.py +++ b/apps/blog/tests/test_views.py @@ -69,8 +69,9 @@ def test_newsletter_forms_render_in_nav_and_footer(client, home_page): resp = client.get("/") html = resp.content.decode() assert resp.status_code == 200 - # Nav has a Subscribe CTA link (no inline form — wireframe spec) - assert 'href="#newsletter"' in html + # Nav has a search form instead of Subscribe CTA + assert 'role="search"' in html + assert 'name="q"' in html # Footer has Connect section with social/RSS links (no newsletter form) assert "Connect" in html assert 'name="source" value="nav"' not in html diff --git a/apps/blog/views.py b/apps/blog/views.py new file mode 100644 index 0000000..43d963d --- /dev/null +++ b/apps/blog/views.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator +from django.http import HttpRequest, HttpResponse +from django.template.response import TemplateResponse + +from apps.blog.models import ArticlePage + +RESULTS_PER_PAGE = 12 +MAX_QUERY_LENGTH = 200 + + +def search(request: HttpRequest) -> HttpResponse: + query = request.GET.get("q", "").strip()[:MAX_QUERY_LENGTH] + results_page = None + paginator = None + + if query: + results = ( + ArticlePage.objects.live() + .public() + .select_related("author", "category") + .prefetch_related("tags__metadata") + .search(query) + ) + paginator = Paginator(results, RESULTS_PER_PAGE) + page_num = request.GET.get("page") + try: + results_page = paginator.page(page_num) + except PageNotAnInteger: + results_page = paginator.page(1) + except EmptyPage: + results_page = paginator.page(paginator.num_pages) + + return TemplateResponse( + request, + "blog/search_results.html", + { + "query": query, + "results": results_page, + "paginator": paginator, + }, + ) diff --git a/config/settings/base.py b/config/settings/base.py index fee1614..adc6d65 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -29,6 +29,7 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.sitemaps", + "django.contrib.postgres", "taggit", "modelcluster", "wagtail.contrib.forms", @@ -152,3 +153,10 @@ STORAGES = { } TAILWIND_APP_NAME = "theme" + +WAGTAILSEARCH_BACKENDS = { + "default": { + "BACKEND": "wagtail.search.backends.database", + "SEARCH_CONFIG": "english", + } +} diff --git a/config/urls.py b/config/urls.py index 8ae28e5..883a54f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -7,6 +7,7 @@ from wagtail import urls as wagtail_urls from wagtail.contrib.sitemaps.views import sitemap from apps.blog.feeds import AllArticlesFeed, CategoryArticlesFeed, TagArticlesFeed +from apps.blog.views import search as search_view from apps.core.views import consent_view, robots_txt urlpatterns = [ @@ -22,6 +23,7 @@ urlpatterns = [ path("feed/tag/{{ active_category.description }}
{% endif %} - -+ {% if results %}{{ results.paginator.count }} result{{ results.paginator.count|pluralize }} for "{{ query }}"{% else %}No results for "{{ query }}"{% endif %} +
+ {% endif %} +No articles match your search.
+Try different keywords or browse all articles.
+Enter a search term to find articles.
+