Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions foundation_cms/search/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 5.2.13 on 2026-05-14 16:44

from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="SearchEvent",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("query_string", models.CharField(db_index=True, max_length=255)),
("language_code", models.CharField(db_index=True, max_length=10)),
("results_count", models.PositiveIntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["created_at"], name="search_sear_created_a297d4_idx"),
models.Index(fields=["query_string", "created_at"], name="search_sear_query_s_c17460_idx"),
models.Index(fields=["language_code", "created_at"], name="search_sear_languag_3a42bf_idx"),
],
},
),
]
Empty file.
19 changes: 19 additions & 0 deletions foundation_cms/search/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.db import models


class SearchEvent(models.Model):
query_string = models.CharField(max_length=255, db_index=True)
language_code = models.CharField(max_length=10, db_index=True)
results_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)

class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["created_at"]),
models.Index(fields=["query_string", "created_at"]),
models.Index(fields=["language_code", "created_at"]),
]

def __str__(self):
return f"{self.query_string} ({self.results_count})"
45 changes: 45 additions & 0 deletions foundation_cms/search/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from unittest.mock import patch

from django.contrib.auth.models import User
from django.test import Client, TestCase
from wagtail.models import Page, Site

from foundation_cms.search.models import SearchEvent


class SearchViewTestCase(TestCase):
def setUp(self):
Expand Down Expand Up @@ -33,3 +37,44 @@ def test_search_returns_multiple_pages(self):
# Ensure both pages appear in the search results
self.assertContains(response, "Page One")
self.assertContains(response, "Page Two")


class SearchLoggingTestCase(TestCase):
def setUp(self):
self.client = Client()
self.root_page = Page.get_first_root_node()
site = Site.objects.get(is_default_site=True)

# Create sample pages to search for
self.page1 = self.root_page.add_child(instance=Page(title="Page One", slug="page-one"))
self.page2 = self.root_page.add_child(instance=Page(title="Page Two", slug="page-two"))

site.save()

@patch("wagtail.contrib.search_promotions.models.Query.add_hit")
def test_logging_on_initial_search_only(self, mock_add_hit):
# First search: should create SearchEvent and call add_hit
response = self.client.get("/en/search/", {"query": "Page"})
self.assertEqual(response.status_code, 200)
self.assertEqual(SearchEvent.objects.count(), 1)

# add_hit should be called for the initial search
mock_add_hit.assert_called_once()

# Pagination: should not create SearchEvent or call add_hit again
response = self.client.get("/en/search/", {"query": "Page", "page": 2})
self.assertEqual(response.status_code, 200)
self.assertEqual(SearchEvent.objects.count(), 1)

# add_hit should not be called again on pagination
mock_add_hit.assert_called_once()

@patch("wagtail.contrib.search_promotions.models.Query.add_hit")
def test_no_logging_on_pagination_only(self, mock_add_hit):
# If only navigating to page=2 without a prior search, should not log
response = self.client.get("/en/search/", {"query": "Page", "page": 2})
self.assertEqual(response.status_code, 200)
self.assertEqual(SearchEvent.objects.count(), 0)

# add_hit should not be called since there was no initial search
mock_add_hit.assert_not_called()
15 changes: 12 additions & 3 deletions foundation_cms/search/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.http import JsonResponse
from django.template.response import TemplateResponse
from wagtail.contrib.search_promotions.models import Query
from wagtail.models import Locale, Page
from wagtail.search.query import PlainText

from foundation_cms.campaigns.models.campaign_page import CampaignPage
from foundation_cms.search.models import SearchEvent
from foundation_cms.search.utils import get_search_backend_for_locale
from foundation_cms.utils import get_default_locale, localize_queryset

Expand All @@ -23,6 +25,7 @@ def search(request):
page = request.GET.get("page", 1)
total_search_results = 0
current_locale = Locale.get_active()
is_initial_search_submit = "page" not in request.GET

# Search
if search_query:
Expand Down Expand Up @@ -53,10 +56,16 @@ def search(request):
# Restore backend relevance order
search_results = sorted(search_results, key=lambda page: id_to_position.get(page.id, len(result_ids)))

# To log this query for use with the "Promoted search results" module:
# Log only on initial submission, not on pagination clicks
if is_initial_search_submit:
SearchEvent.objects.create(
query_string=search_query.strip().lower(),
language_code=current_locale.language_code,
results_count=total_search_results,
)

# query = Query.get(search_query)
# query.add_hit()
query = Query.get(search_query)
query.add_hit()

else:
search_results = Page.objects.none()
Expand Down
8 changes: 8 additions & 0 deletions foundation_cms/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS=(bool, False),
UNSUBSCRIBE_NEWSLETTER_ENDPOINT=(str, ""),
TEMP_SEARCH_RELATED_CONTENT_PAGE_IDS=(list, []),
WAGTAILSEARCH_HITS_MAX_AGE=(int, 7),
)

# Read in the environment
Expand Down Expand Up @@ -258,6 +259,7 @@
"wagtail.contrib.table_block",
"wagtail.contrib.frontend_cache",
"wagtail.contrib.settings",
"wagtail.contrib.search_promotions",
"wagtail_color_panel",
"wagtailmedia",
"wagtailinventory",
Expand Down Expand Up @@ -309,6 +311,7 @@
"foundation_cms.images",
"foundation_cms.footer",
"foundation_cms.navigation",
"foundation_cms.search",
],
)
)
Expand Down Expand Up @@ -927,3 +930,8 @@ class DatabasesDict(TypedDict):
EDITABLE_FOOTER = env("EDITABLE_FOOTER", default=False)
# Use cms editable nav
EDITABLE_NAV = env("EDITABLE_NAV", default=False)

# Number of days(default 7) to keep search hits in the DB. Queries older than this will be removed by
# the searchpromotions_garbage_collect command ./manage.py searchpromotions_garbage_collect.
# https://docs.wagtail.org/en/stable/reference/settings.html#wagtailsearch-hits-max-age
WAGTAILSEARCH_HITS_MAX_AGE = env("WAGTAILSEARCH_HITS_MAX_AGE")
Loading