feat: implement article search with PostgreSQL full-text search #42
@@ -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(
|
||||
|
||||
140
apps/blog/tests/test_search.py
Normal file
140
apps/blog/tests/test_search.py
Normal file
@@ -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"<p>{summary} in detail.</p>")],
|
||||
)
|
||||
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", "<p>prose content here</p>"),
|
||||
("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
|
||||
@@ -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
|
||||
|
||||
43
apps/blog/views.py
Normal file
43
apps/blog/views.py
Normal file
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<slug:tag_slug>/", TagArticlesFeed(), name="rss_feed_by_tag"),
|
||||
path("sitemap.xml", sitemap),
|
||||
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
||||
path("search/", search_view, name="search"),
|
||||
path("", include(wagtail_urls)),
|
||||
]
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ python manage.py tailwind install --no-input
|
||||
python manage.py tailwind build
|
||||
python manage.py migrate --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
python manage.py update_index
|
||||
|
||||
# Set Wagtail site hostname from first entry in ALLOWED_HOSTS
|
||||
python manage.py shell -c "
|
||||
|
||||
@@ -37,11 +37,10 @@ def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None:
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_nav_subscribe_cta_present(page: Page, base_url: str) -> None:
|
||||
def test_nav_search_box_present(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
nav = page.locator("nav")
|
||||
# Nav has a Subscribe CTA link (not a form — wireframe spec)
|
||||
expect(nav.get_by_role("link", name="Subscribe")).to_be_visible()
|
||||
expect(nav.locator('input[name="q"]')).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
|
||||
@@ -23,12 +23,20 @@
|
||||
<p class="text-zinc-600 dark:text-zinc-400 mb-6">{{ active_category.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Category Filters -->
|
||||
<div class="flex flex-wrap gap-3 mb-4">
|
||||
<!-- Filters / Search -->
|
||||
<div class="flex flex-col md:flex-row justify-between gap-6 mb-4">
|
||||
<!-- Category Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/articles/{% if active_tag %}?tag={{ active_tag }}{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if not active_category %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if not active_category %}aria-current="page"{% endif %}>Categories</a>
|
||||
{% for category_link in category_links %}
|
||||
<a href="{{ category_link.url }}{% if active_tag %}?tag={{ active_tag }}{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if active_category and active_category.slug == category_link.category.slug %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if active_category and active_category.slug == category_link.category.slug %}aria-current="page"{% endif %}>{{ category_link.category.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form action="{% url 'search' %}" method="get" role="search" class="relative w-full md:w-64">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
|
||||
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-10 pr-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tag Filters -->
|
||||
|
||||
59
templates/blog/search_results.html
Normal file
59
templates/blog/search_results.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if query %}Search: {{ query }}{% else %}Search{% endif %} | No Hype AI{% endblock %}
|
||||
{% block head_meta %}
|
||||
<meta name="robots" content="noindex" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
|
||||
<h1 class="font-display font-black text-4xl md:text-6xl mb-6">Search</h1>
|
||||
<form action="{% url 'search' %}" method="get" role="search" class="relative w-full md:w-96">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||
<input type="search" name="q" value="{{ query }}" placeholder="Search articles..." autofocus
|
||||
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-11 pr-4 py-3 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
|
||||
</form>
|
||||
{% if query %}
|
||||
<p class="mt-4 font-mono text-sm text-zinc-500">
|
||||
{% if results %}{{ results.paginator.count }} result{{ results.paginator.count|pluralize }} for "{{ query }}"{% else %}No results for "{{ query }}"{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
<!-- Results -->
|
||||
<div class="space-y-8">
|
||||
{% for article in results %}
|
||||
{% include 'components/article_card.html' with article=article %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if results.has_previous or results.has_next %}
|
||||
<nav aria-label="Pagination" class="mt-12 flex justify-center items-center gap-4 font-mono text-sm">
|
||||
{% if results.has_previous %}
|
||||
<a href="?q={{ query|urlencode }}&page={{ results.previous_page_number }}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">← Previous</a>
|
||||
{% endif %}
|
||||
<span class="text-zinc-500">Page {{ results.number }} of {{ paginator.num_pages }}</span>
|
||||
{% if results.has_next %}
|
||||
<a href="?q={{ query|urlencode }}&page={{ results.next_page_number }}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">Next →</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% elif query %}
|
||||
<!-- No Results -->
|
||||
<div class="py-16 text-center">
|
||||
<svg class="w-16 h-16 text-zinc-300 dark:text-zinc-700 mx-auto mb-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||
<p class="font-mono text-zinc-500 mb-2">No articles match your search.</p>
|
||||
<p class="font-mono text-sm text-zinc-400">Try different keywords or browse <a href="/articles/" class="text-brand-cyan hover:underline">all articles</a>.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="py-16 text-center">
|
||||
<svg class="w-16 h-16 text-zinc-300 dark:text-zinc-700 mx-auto mb-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||
<p class="font-mono text-zinc-500">Enter a search term to find articles.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -19,7 +19,11 @@
|
||||
{% for category in category_nav_items %}
|
||||
<a href="{{ category.url }}" class="hover:text-brand-cyan transition-colors {% if category.url in request.path %}text-brand-cyan{% endif %}" {% if category.url in request.path %}aria-current="page"{% endif %}>{{ category.name }}</a>
|
||||
{% endfor %}
|
||||
<a href="#newsletter" class="px-5 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all border border-transparent dark:border-zinc-700">Subscribe</a>
|
||||
<form action="{% url 'search' %}" method="get" role="search" class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
|
||||
class="w-48 bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-9 pr-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle + Hamburger -->
|
||||
@@ -44,6 +48,13 @@
|
||||
{% for category in category_nav_items %}
|
||||
<a href="{{ category.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors {% if category.url in request.path %}text-brand-cyan{% endif %}" {% if category.url in request.path %}aria-current="page"{% endif %}>{{ category.name }}</a>
|
||||
{% endfor %}
|
||||
<form action="{% url 'search' %}" method="get" role="search" class="pt-2 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
|
||||
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-9 pr-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="source" value="nav-mobile" />
|
||||
|
||||
Reference in New Issue
Block a user